Support querying for sessions by user identifier

Fixes gh-7
This commit is contained in:
Rob Winch
2015-08-14 16:27:56 -05:00
parent 77eb6cfd71
commit 881ca7c2d4
34 changed files with 8660 additions and 14 deletions

View File

@@ -0,0 +1,172 @@
= Spring Session - find by username
Rob Winch
:toc:
This guide describes how to use Spring Session to find sessions by username.
NOTE: The completed guide can be found in the <<findbyusername-sample, findbyusername application>>.
[[findbyusername-assumptions]]
== Assumptions
The guide assumes you have already added Spring Session using the built in Redis configuration support to your application.
The guide also assumes you have already applied Spring Security to your application.
However, we the guide will be somewhat general purpose and can be applied to any technology with minimal changes we will discuss.
[NOTE]
====
If you need to learn how to add Spring Session to your project, please refer to the listing of link:../#samples[samples and guides]
====
== About the Sample
Our sample is using this feature to invalidate the users session that might have been compromised.
Consider the following scenario:
* User goes to library and authenticates to the application
* User goes home and realizes they forgot to log out
* User can log in and terminate the session from the library using clues like the location, created time, last accessed time, etc.
Wouldn't it be nice if we could allow the user to invalidate the session at the library from any device they authenticate with?
This sample demonstrates how this is possible.
[[findbyusernamesessionrepository]]
== FindByUsernameSessionRepository
In order to look up a user by their username, you must first choose a `SessionRepository` that implements <<index.doc#ap-findbyusernamesessionrepository,FindByUsernameSessionRepository>>.
Our sample application assumes that the Redis support is already setup, so we are ready to go.
== Mapping the username
`FindByUsernameSessionRepository` can only find a session by the username, if the developer instructs Spring Session what user is associated with the `Session`.
This is done by ensuring that the session attribute with the name `Session.FindByUsernameSessionRepository` is populated with the username.
Generally, speaking this can be done with the following code immediately after the user authenticates:
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/session/SpringSessionPrincipalNameSuccessHandler.java[tags=set-username]
----
== Mapping the username with Spring Security
Since we are using Spring Security, it makes perfect sense to provide a custom `AuthenticationSuccessHandler` that populates the username.
For example:
[NOTE]
====
We plan to provide first class integration with Spring Security to make this process easier in the future.
For details track https://github.com/spring-projects/spring-session/issues/266[gh-266].
====
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/session/SpringSessionPrincipalNameSuccessHandler.java[tags=class]
----
In order to support multiple handlers, we need to also create a custom `AuthenticationSuccessHandler` that delegates to multiple `AuthenticationSuccessHandler` instances.
For example:
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/session/CompositeAuthenticationSuccessHandler.java[tags=class]
----
Next we need to provide a custom `AuthenticationSuccessHandler` with Spring Security.
In Java configuration, we can do this using the following:
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/config/SecurityConfig.java[tags=handler]
----
We can then configure authentication success with the following:
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/config/SecurityConfig.java[tags=config]
----
== Adding Additional Data to Session
It may be nice to associate additional information (i.e. IP Address, the browser, location, etc) to the session.
This makes it easier for the user to know which session they are looking at.
To do this simply determine which session attribute you want to use and what information you wish to provide.
Then create a Java bean that is added as a session attribute.
For example, our sample application includes the location and access type of the session
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/session/SessionDetails.java[tags=class]
----
We then inject that information into the session on each HTTP request using a `SessionDetailsFilter`.
For example:
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/session/SessionDetailsFilter.java[tags=dofilterinternal]
----
We obtain the information we want and then set the `SessionDetails` as an attribute in the `Session`.
When we retrieve the `Session` by username, we can then use the session to access our `SessionDetails` just like any other session attribute.
[NOTE]
====
You might be wondering at this point why Spring Session does not provide `SessionDetails` functionality out of the box.
The reason, is twofold.
The first is that it is very trivial for applications to implement this themselves.
The second reason is that the information that is populated in the session (and how frequently that information is updated) is highly application dependent.
====
== Finding sessions for a specific user
We can now find all the sessions for a specific user.
[source,java,indent=0]
----
include::{samples-dir}findbyusername/src/main/java/sample/mvc/IndexController.java[tags=findbyusername]
----
In our instance, we find all sessions for the currently logged in user.
However, this could easily be modified for an administrator to use a form to specify which user to look up.
[[findbyusername-sample]]
== findbyusername Sample Application
=== Running the findbyusername Sample Application
You can run the sample by obtaining the {download-url}[source code] and invoking the following command:
[NOTE]
====
For the sample to work, you must http://redis.io/download[install Redis 2.8+] on localhost and run it with the default port (6379).
Alternatively, you can update the `JedisConnectionFactory` to point to a Redis server.
====
----
$ ./gradlew :samples:findbyusername:tomcatRun
----
You should now be able to access the application at http://localhost:8080/
=== Exploring the security Sample Application
Try using the application. Enter the following to log in:
* **Username** _user_
* **Password** _password_
Now click the **Login** button.
You should now see a message indicating your are logged in with the user entered previously.
You should also see a listing of active sessions for the currently logged in user.
Let's emulate the flow we discussed in the <<About the Sample>> section
* Open a new incognito window and navigate to http://localhost:8080/
* Enter the following to log in:
** **Username** _user_
** **Password** _password_
* Terminate your original session
* Refresh the original window and see you are logged out

View File

@@ -51,6 +51,10 @@ If you are looking to get started with Spring Session, the best place to start i
| Demonstrates how to use Spring Session in a REST application to support authenticating with a header.
| link:guides/rest.html[REST Guide]
| {gh-samples-url}findbyusername[Find by Username]
| Demonstrates how to use Spring Session to find sessions by username.
| link:guides/findbyusername.html[Find by Username]
| {gh-samples-url}users[Multiple Users]
| Demonstrates how to use Spring Session to manage multiple simultaneous browser sessions (i.e Google Accounts).
| link:guides/users.html[Manage Multiple Users Guide]
@@ -304,6 +308,19 @@ A `SessionRepository` is in charge of creating, retrieving, and persisting `Sess
If possible, developers should not interact directly with a `SessionRepository` or a `Session`.
Instead, developers should prefer interacting with `SessionRepository` and `Session` indirectly through the <<httpsession,HttpSession>> and <<websocket,WebSocket>> integration.
[[api-findbyusernamesessionrepository]]
== FindByUsernameSessionRepository
Spring Session's most basic API for using a `Session` is the `SessionRepository`.
This API is intentionally very simple, so that it is easy to provide additional implementations with basic functionality.
The `FindByUsernameSessionRepository` adds a single method to look up all the sessions for a particular user.
This is done by ensuring that the session attribute with the name `Session.PRINCIPAL_NAME_ATTRIBUTE_NAME` is populated with the username.
It is the responsibility of the developer to ensure the attribute is populated since Spring Session is not aware of the authentication mechanism being used.
Some `SessionRepository` implementations may choose to implement `FindByUsernameSessionRepository` also.
For example, Spring's Redis support implements `FindByUsernameSessionRepository`.
[[api-redisoperationssessionrepository]]
=== RedisOperationsSessionRepository

View File

@@ -0,0 +1,4 @@
Demonstrates using Spring Session to lookup a user's session by the username.
The sample provides a hook to add the current username to the session (required for finding the user) by providing a custom implementation of Spring Security's `AuthenticationSuccessHandler`.
NOTE: This product includes GeoLite2 data created by MaxMind, available from http://www.maxmind.com

View File

@@ -0,0 +1,54 @@
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion")
}
}
apply plugin: 'spring-boot'
apply from: JAVA_GRADLE
tasks.findByPath("artifactoryPublish")?.enabled = false
group = 'samples'
dependencies {
compile project(':spring-session'),
"org.springframework.boot:spring-boot-starter-redis",
"org.springframework.boot:spring-boot-starter-web",
"org.springframework.boot:spring-boot-starter-thymeleaf",
"nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect",
"org.springframework.security:spring-security-web:$springSecurityVersion",
"org.springframework.security:spring-security-config:$springSecurityVersion",
"com.maxmind.geoip2:geoip2:2.3.1",
"org.apache.httpcomponents:httpclient:4.4.1"
testCompile "org.springframework.boot:spring-boot-starter-test",
'org.easytesting:fest-assert:1.4'
integrationTestCompile gebDependencies,
"org.spockframework:spock-spring:$spockVersion"
}
integrationTest {
doFirst {
def port = reservePort()
def host = 'localhost:' + port
systemProperties['geb.build.baseUrl'] = 'http://'+host+'/'
systemProperties['geb.build.reportsDir'] = 'build/geb-reports'
systemProperties['server.port'] = port
systemProperties['management.port'] = 0
}
}
def reservePort() {
def socket = new ServerSocket(0)
def result = socket.localPort
socket.close()
result
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright 2002-2015 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 sample
import geb.Browser
import geb.spock.*
import org.openqa.selenium.htmlunit.HtmlUnitDriver
import org.springframework.boot.test.IntegrationTest
import org.springframework.boot.test.SpringApplicationContextLoader
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.web.WebAppConfiguration
import pages.*
import sample.pages.HomePage
import sample.pages.LoginPage
import spock.lang.Stepwise
/**
* Tests findbyusername web application
*
* @author Rob Winch
*/
@Stepwise
@ContextConfiguration(classes = FindByUsernameApplication, loader = SpringApplicationContextLoader)
@WebAppConfiguration
@IntegrationTest
class FindByUsernameTests extends MultiBrowserGebSpec {
def 'Unauthenticated user sent to log in page'() {
given: 'unauthenticated user request protected page'
withBrowserSession a, {
via HomePage
then: 'sent to the log in page'
at LoginPage
}
}
def 'Log in views home page'() {
when: 'log in successfully'
a.login()
then: 'sent to original page'
a.at HomePage
and: 'the username is displayed'
a.username == 'user'
and: 'Spring Session Management is being used'
a.driver.manage().cookies.find { it.name == 'SESSION' }
and: 'Standard Session is NOT being used'
!a.driver.manage().cookies.find { it.name == 'JSESSIONID' }
and: 'Session id exists and terminate disabled'
a.sessionId
a.terminate(a.sessionId).@disabled
}
def 'Other Browser: Unauthenticated user sent to log in page'() {
given:
withBrowserSession b, {
via HomePage
then: 'sent to the log in page'
at LoginPage
}
}
def 'Other Browser: Log in views home page'() {
when: 'log in successfully'
b.login()
then: 'sent to original page'
b.at HomePage
and: 'the username is displayed'
b.username == 'user'
and: 'Spring Session Management is being used'
b.driver.manage().cookies.find { it.name == 'SESSION' }
and: 'Standard Session is NOT being used'
!b.driver.manage().cookies.find { it.name == 'JSESSIONID' }
and: 'Session id exists and terminate disabled'
b.sessionId
b.terminate(b.sessionId).@disabled
}
def 'Other Browser: Terminate Original'() {
setup: 'get the session id from browser a'
def sessionId = a.sessionId
when: 'we terminate browser a session'
b.terminate(sessionId).click(HomePage)
then: 'session is not listed'
!b.terminate(sessionId)
when: 'browser a visits home page after session was terminated'
a.via HomePage
then: 'browser a sent to log in'
a.at LoginPage
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2002-2015 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 sample;
import geb.*
import spock.lang.*
/**
* https://github.com/kensiprell/geb-multibrowser
*/
abstract class MultiBrowserGebSpec extends Specification {
String gebConfEnv = null
String gebConfScript = null
// Map of geb browsers which can be referenced by name in the spec
// THese currently share the same config. This is not a problem for
// my uses, but I can see potential for wanting to configure different
// browsers separately
@Shared _browsers = createBrowserMap()
def currentBrowser
def createBrowserMap() {
[:].withDefault { new Browser(createConf()) }
}
Configuration createConf() {
// Use the standard configured geb driver, but turn off cacheing so
// we can run multiple
def conf = new ConfigurationLoader(gebConfEnv).getConf(gebConfScript)
conf.cacheDriver = false
return conf
}
def withBrowserSession(browser, Closure c) {
currentBrowser = browser
def returnedValue = c.call()
currentBrowser = null
returnedValue
}
void resetBrowsers() {
_browsers.each { k, browser ->
if (browser.config?.autoClearCookies) {
browser.clearCookiesQuietly()
}
browser.quit()
}
_browsers = createBrowserMap()
}
def propertyMissing(String name) {
if(currentBrowser) {
return currentBrowser."$name"
} else {
return _browsers[name]
}
}
def methodMissing(String name, args) {
if(currentBrowser) {
return currentBrowser."$name"(*args)
} else {
def browser = _browsers[name]
if(args) {
return browser."${args[0]}"(*(args[1..-1]))
} else {
return browser
}
}
}
def propertyMissing(String name, value) {
if(!currentBrowser) throw new IllegalArgumentException("No context for setting property $name")
currentBrowser."$name" = value
}
private isSpecStepwise() {
this.class.getAnnotation(Stepwise) != null
}
def cleanup() {
if (!isSpecStepwise()) resetBrowsers()
}
def cleanupSpec() {
if (isSpecStepwise()) resetBrowsers()
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2002-2015 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 sample.pages
import geb.*
/**
* The home page
*
* @author Rob Winch
*/
class HomePage extends Page {
static url = ''
static at = { assert driver.title == 'Spring Session Sample - Secured Content'; true}
static content = {
username { $('#un').text() }
logout(to:LoginPage) { $('input[type=submit]').click() }
sessions { moduleList AttributeRow, $("table tr").tail() }
terminate(required:false) { sessionId -> $("#terminate-$sessionId") }
sessionId { $("#session-id").text() }
}
}
class AttributeRow extends Module {
static content = {
cell { $("td", it) }
location { cell(0).text() }
created { cell(1).text() }
lastUpdated { cell(2).text() }
information { cell(3).text() }
terminate { cell(4).text() }
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2002-2015 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 sample.pages
import geb.*
/**
* The Links Page
*
* @author Rob Winch
*/
class LoginPage extends Page {
static url = '/login'
static at = { assert driver.title == 'Spring Session Sample - Log In'; true}
static content = {
form { $('form') }
submit { $('button[type=submit]') }
login(required:false) { user='user', pass='password' ->
form.username = user
form.password = pass
submit.click(HomePage)
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2002-2015 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 sample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/**
* @author Rob Winch
*/
@Configuration
@ComponentScan
@EnableAutoConfiguration
public class FindByUsernameApplication {
public static void main(String[] args) {
SpringApplication.run(FindByUsernameApplication.class, args);
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2002-2015 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 sample.config;
import java.io.InputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.maxmind.geoip2.DatabaseReader;
/**
*
* @author Rob Winch
*
*/
@Configuration
public class GeoConfig {
@Bean
public DatabaseReader geoDatabaseReader(@Value("classpath:GeoLite2-City.mmdb") InputStream geoInputStream) throws Exception {
return new DatabaseReader.Builder(geoInputStream).build();
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-2015 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 sample.config;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
/**
*
* @author Rob Winch
*
*/
// tag::class[]
@EnableRedisHttpSession // <1>
public class HttpSessionConfig { }
// end::class[]

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2002-2015 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 sample.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import sample.session.CompositeAuthenticationSuccessHandler;
import sample.session.SpringSessionPrincipalNameSuccessHandler;
/**
* @author Rob Winch
*/
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// tag::config[]
@Override
protected void configure(HttpSecurity http) throws Exception {
CompositeAuthenticationSuccessHandler successHandler = createHandler();
http
.formLogin()
.successHandler(successHandler)
.loginPage("/login")
.permitAll()
.and()
.authorizeRequests()
.antMatchers("/resources/**").permitAll()
.anyRequest().authenticated()
.and()
.logout()
.permitAll();
}
// end::config[]
// tag::handler[]
private CompositeAuthenticationSuccessHandler createHandler() {
SpringSessionPrincipalNameSuccessHandler setUsernameHandler =
new SpringSessionPrincipalNameSuccessHandler();
SavedRequestAwareAuthenticationSuccessHandler defaultHandler =
new SavedRequestAwareAuthenticationSuccessHandler();
CompositeAuthenticationSuccessHandler successHandler =
new CompositeAuthenticationSuccessHandler(setUsernameHandler, defaultHandler);
return successHandler;
}
// end::handler[]
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("user").password("password").roles("USER");
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2002-2015 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 sample.mvc;
import java.security.Principal;
import java.util.Collection;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.session.ExpiringSession;
import org.springframework.session.FindByPrincipalNameSessionRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
/**
* Controller for sending the user to the login view.
*
* @author Rob Winch
*
*/
@Controller
public class IndexController {
// tag::findbyusername[]
@Autowired
FindByPrincipalNameSessionRepository<? extends ExpiringSession> sessions;
@RequestMapping("/")
public String index(Principal principal, Model model) {
Collection<? extends ExpiringSession> usersSessions =
sessions.findByPrincipalName(principal.getName()).values();
model.addAttribute("sessions", usersSessions);
return "index";
}
// end::findbyusername[]
@RequestMapping(value = "/sessions/{sessionIdToDelete}", method = RequestMethod.DELETE)
public String removeSession(Principal principal, @PathVariable String sessionIdToDelete) {
Set<String> usersSessionIds = sessions.findByPrincipalName(principal.getName()).keySet();
if(usersSessionIds.contains(sessionIdToDelete)) {
sessions.delete(sessionIdToDelete);
}
return "redirect:/";
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2002-2015 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 sample.mvc;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Returns view for log in page
*
* @author Rob Winch
*/
@Controller
public class LoginController {
@RequestMapping("/login")
public String login() {
return "login";
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2002-2015 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 sample.session;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
/**
* @author Rob Winch
*
*/
// tag::class[]
public class CompositeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private List<AuthenticationSuccessHandler> handlers;
public CompositeAuthenticationSuccessHandler(AuthenticationSuccessHandler... handlers) {
super();
this.handlers = Arrays.asList(handlers);
}
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
for(AuthenticationSuccessHandler handler : handlers) {
handler.onAuthenticationSuccess(request, response, authentication);
}
}
}
// end::class[]

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2002-2015 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 sample.session;
import java.io.Serializable;
/**
* An example of how users can provide details about their session.
*
* @author Rob Winch
* @see SessionDetailsFilter
*/
// tag::class[]
public class SessionDetails implements Serializable {
private String location;
private String accessType;
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getAccessType() {
return accessType;
}
public void setAccessType(String accessType) {
this.accessType = accessType;
}
private static final long serialVersionUID = 8850489178248613501L;
}
// end::class[]

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2002-2015 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 sample.session;
import java.io.IOException;
import java.net.InetAddress;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.model.CityResponse;
/**
* Inserts the session details into the session for every request. Some users
* may prefer to insert session details only after authentication. This is fine,
* but it may be valuable to the most up to date information so that if someone
* stole the user's session id it can be observed.
*
* @author Rob Winch
*
*/
// tag::class[]
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 101)
public class SessionDetailsFilter extends OncePerRequestFilter {
static final String UNKNOWN = "Unknown";
private DatabaseReader reader;
@Autowired
public SessionDetailsFilter(DatabaseReader reader) {
this.reader = reader;
}
// tag::dofilterinternal[]
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
chain.doFilter(request, response);
HttpSession session = request.getSession(false);
if (session != null) {
String remoteAddr = getRemoteAddress(request);
String geoLocation = getGeoLocation(remoteAddr);
SessionDetails details = new SessionDetails();
details.setAccessType(request.getHeader("User-Agent"));
details.setLocation(remoteAddr + " " + geoLocation);
session.setAttribute("SESSION_DETAILS", details);
}
}
// end::dofilterinternal[]
String getGeoLocation(String remoteAddr) {
try {
CityResponse city = reader.city(InetAddress.getByName(remoteAddr));
String cityName = city.getCity().getName();
String countryName = city.getCountry().getName();
if(cityName == null && countryName == null) {
return null;
} else if(cityName == null) {
return countryName;
} else if(countryName == null) {
return cityName;
}
return cityName + ", " + countryName;
} catch (Exception e) {
return UNKNOWN;
}
}
private String getRemoteAddress(HttpServletRequest request) {
String remoteAddr = request.getHeader("X-FORWARDED-FOR");
if (remoteAddr == null) {
remoteAddr = request.getRemoteAddr();
} else if (remoteAddr.contains(",")) {
remoteAddr = remoteAddr.split(",")[0];
}
return remoteAddr;
}
}
//end::class[]

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2002-2015 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 sample.session;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.session.FindByPrincipalNameSessionRepository;
import org.springframework.session.Session;
/**
* Inserts the username into Spring session after we successfully authenticate.
* Adding the principal name to the session is a requirement of
* {@link FindByPrincipalNameSessionRepository}
*
* @author Rob Winch
*/
// tag::class[]
public class SpringSessionPrincipalNameSuccessHandler
implements AuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
HttpSession session = request.getSession();
String currentUsername = authentication.getName();
// tag::set-username[]
session.setAttribute(Session.PRINCIPAL_NAME_ATTRIBUTE_NAME, currentUsername);
// end::set-username[]
}
}
// end::class[]

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 MiB

View File

@@ -0,0 +1,2 @@
spring.thymeleaf.cache=false
spring.template.cache=false

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,36 @@
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorator="layout">
<head>
<title>Secured Content</title>
</head>
<body>
<div layout:fragment="content">
<h1>Secured Page</h1>
<p>This page is secured using Spring Boot, Spring Session, and Spring Security.</p>
<p>Your current session id is <span id="session-id" th:text="${#httpSession.id}"></span></p>
<table class="table table-stripped">
<tr>
<th>Id Suffix</th>
<th>Location</th>
<th>Created</th>
<th>Last Updated</th>
<th>Information</th>
<th>Terminate</th>
</tr>
<tr th:each="session : ${sessions}" th:with="details=${session.getAttribute('SESSION_DETAILS')}">
<td th:text="${session.id.substring(30)}"></td>
<td th:text="${details.location}"></td>
<td th:text="${#dates.format(new java.util.Date(session.creationTime),'dd/MMM/yyyy HH:mm:ss')}"></td>
<td th:text="${#dates.format(new java.util.Date(session.lastAccessedTime),'dd/MMM/yyyy HH:mm:ss')}"></td>
<td th:text="${details.accessType}"></td>
<td>
<form th:action="@{'/sessions/' + ${session.id}}" th:method="delete">
<input th:id="'terminate-' + ${session.id}" type="submit" value="Terminate" th:disabled="${session.id == #httpSession.id}"/>
</form>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-spring4-3.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">Spring Session Sample</title>
<link rel="icon" type="image/x-icon" th:href="@{/resources/img/favicon.ico}" href="../static/img/favicon.ico"/>
<link th:href="@{/resources/css/bootstrap.css}" href="../static/css/bootstrap.css" rel="stylesheet"></link>
<style type="text/css">
/* Sticky footer styles
-------------------------------------------------- */
html,
body {
height: 100%;
/* The html and body elements cannot have any padding or margin. */
}
/* Wrapper for page content to push down footer */
#wrap {
min-height: 100%;
height: auto !important;
height: 100%;
/* Negative indent footer by it's height */
margin: 0 auto -60px;
}
/* Set the fixed height of the footer here */
#push,
#footer {
height: 60px;
}
#footer {
background-color: #f5f5f5;
}
/* Lastly, apply responsive CSS fixes as necessary */
@media (max-width: 767px) {
#footer {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
}
}
/* Custom page CSS
-------------------------------------------------- */
/* Not required for template or sticky footer method. */
.container {
width: auto;
max-width: 680px;
}
.container .credit {
margin: 20px 0;
text-align: center;
}
a {
color: green;
}
.navbar-form {
margin-left: 1em;
}
</style>
<link th:href="@{resources/css/bootstrap-responsive.css}" href="/static/css/bootstrap-responsive.css" rel="stylesheet"></link>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head>
<body>
<div id="wrap">
<div class="navbar navbar-inverse navbar-static-top">
<div class="navbar-inner">
<div class="container">
<a class="brand" th:href="@{/}"><img th:src="@{/resources/img/logo.png}" alt="Spring Security Sample"/></a>
<div class="nav-collapse collapse"
th:with="currentUser=${#httpServletRequest.userPrincipal?.principal}">
<div th:if="${currentUser != null}">
<form class="navbar-form pull-right" th:action="@{/logout}" method="post">
<input type="submit" value="Log out" />
</form>
<p id="un" class="navbar-text pull-right" th:text="${currentUser.username}">
sample_user
</p>
</div>
<ul class="nav">
</ul>
</div>
</div>
</div>
</div>
<!-- Begin page content -->
<div class="container">
<div class="alert alert-success"
th:if="${globalMessage}"
th:text="${globalMessage}">
Some Success message
</div>
<div layout:fragment="content">
Fake content
</div>
</div>
<div id="push"><!-- --></div>
</div>
<div id="footer">
<div class="container">
<p class="muted credit">This product includes GeoLite2 data created by MaxMind, available from <a href="http://www.maxmind.com">http://www.maxmind.com</a>.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layout">
<head>
<title>Log In</title>
</head>
<body>
<div layout:fragment="content">
<form name="f" th:action="@{/login}" method="post">
<fieldset>
<legend>Please Login</legend>
<div th:if="${param.error}" class="alert alert-error">Invalid
username and password.</div>
<div th:if="${param.logout}" class="alert alert-success">You
have been logged out.</div>
<label for="username">Username</label> <input type="text"
id="username" name="username" /> <label for="password">Password</label>
<input type="password" id="password" name="password" />
<div class="form-actions">
<button type="submit" class="btn">Log in</button>
</div>
</fieldset>
</form>
<div class="container">
<p>The intention is to demo a way to terminate a user's active
session without access to the device. Consider the following</p>
<ul>
<li>User goes to library and authenticates to the application</li>
<li>User goes home and realizes they forgot to log out</li>
<li>User can log in and terminate the session from the library
using clues like the location, created time, last accessed time,
etc.</li>
</ul>
<p>To emulate the workflow:</p>
<ul>
<li>Sign in with the <b>username</b> "user" and <b>password</b> "password"</li>
<li>Sign in again using an incognito window</li>
<li>Terminate your original session</li>
<li>Refresh the original window and see you are logged out</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2002-2015 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 sample.session;
import static org.fest.assertions.Assertions.*;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.maxmind.geoip2.DatabaseReader;
import sample.config.GeoConfig;
/**
* @author Rob Winch
*
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = GeoConfig.class)
public class SessionDetailsFilterTests {
@Autowired
DatabaseReader reader;
SessionDetailsFilter filter;
@Before
public void setup() {
filter = new SessionDetailsFilter(reader);
}
@Test
public void getGeoLocationHanldesInvalidIp() {
assertThat(filter.getGeoLocation("a")).isEqualTo(SessionDetailsFilter.UNKNOWN);
}
@Test
public void getGeoLocationNullCity() {
assertThat(filter.getGeoLocation("22.231.113.64")).isEqualTo("United States");
}
@Test
public void getGeoLocationBoth() {
assertThat(filter.getGeoLocation("184.154.83.119")).isEqualTo("Chicago, United States");
}
}

View File

@@ -3,6 +3,7 @@ rootProject.name = 'spring-session-build'
include 'docs'
include 'samples:boot'
include 'samples:findbyusername'
include 'samples:hazelcast'
include 'samples:httpsession'
include 'samples:httpsession-xml'

View File

@@ -17,6 +17,9 @@ package org.springframework.session.data.redis;
import static org.fest.assertions.Assertions.assertThat;
import java.util.Map;
import java.util.UUID;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -25,11 +28,13 @@ import org.springframework.context.ApplicationListener;
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.RedisOperations;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.session.FindByPrincipalNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@@ -45,27 +50,36 @@ import org.springframework.test.context.web.WebAppConfiguration;
@WebAppConfiguration
public class RedisOperationsSessionRepositoryITests<S extends Session> {
@Autowired
private SessionRepository<S> repository;
private FindByPrincipalNameSessionRepository<S> repository;
@Autowired
private SessionEventRegistry registry;
@Autowired
RedisOperations<Object, Object> redis;
@Test
public void saves() throws InterruptedException {
String username = "saves-"+System.currentTimeMillis();
String usernameSessionKey = RedisOperationsSessionRepository.PRINCIPAL_NAME_PREFIX + username;
S toSave = repository.createSession();
String expectedAttributeName = "a";
String expectedAttributeValue = "b";
toSave.setAttribute(expectedAttributeName, expectedAttributeValue);
Authentication toSaveToken = new UsernamePasswordAuthenticationToken("user","password", AuthorityUtils.createAuthorityList("ROLE_USER"));
Authentication toSaveToken = new UsernamePasswordAuthenticationToken(username,"password", AuthorityUtils.createAuthorityList("ROLE_USER"));
SecurityContext toSaveContext = SecurityContextHolder.createEmptyContext();
toSaveContext.setAuthentication(toSaveToken);
toSave.setAttribute("SPRING_SECURITY_CONTEXT", toSaveContext);
toSave.setAttribute(Session.PRINCIPAL_NAME_ATTRIBUTE_NAME, username);
registry.clear();
repository.save(toSave);
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent()).isInstanceOf(SessionCreatedEvent.class);
assertThat(redis.boundSetOps(usernameSessionKey).members()).contains(toSave.getId());
Session session = repository.getSession(toSave.getId());
@@ -79,6 +93,7 @@ public class RedisOperationsSessionRepositoryITests<S extends Session> {
assertThat(repository.getSession(toSave.getId())).isNull();
assertThat(registry.getEvent()).isInstanceOf(SessionDestroyedEvent.class);
assertThat(redis.boundSetOps(usernameSessionKey).members()).excludes(toSave.getId());
assertThat(registry.getEvent().getSession().getAttribute(expectedAttributeName)).isEqualTo(expectedAttributeValue);
@@ -103,6 +118,28 @@ public class RedisOperationsSessionRepositoryITests<S extends Session> {
assertThat(session.getAttribute("1")).isEqualTo("2");
}
@Test
public void findByPrincipalName() throws Exception {
String principalName = "findByPrincipalName" + UUID.randomUUID();
S toSave = repository.createSession();
toSave.setAttribute(Session.PRINCIPAL_NAME_ATTRIBUTE_NAME, principalName);
repository.save(toSave);
Map<String, S> findByPrincipalName = repository.findByPrincipalName(principalName);
assertThat(findByPrincipalName).hasSize(1);
assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId());
repository.delete(toSave.getId());
registry.receivedEvent();
findByPrincipalName = repository.findByPrincipalName(principalName);
assertThat(findByPrincipalName).hasSize(0);
assertThat(findByPrincipalName.keySet()).excludes(toSave.getId());
}
static class SessionEventRegistry implements ApplicationListener<AbstractSessionEvent> {
private AbstractSessionEvent event;
private final Object lock = new Object();

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2002-2015 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;
import java.util.Map;
/**
* Extends a basic {@link SessionRepository} to allow finding a session id by
* the principal name. The principal name is defined by the {@link Session}
* attribute with the name {@link Session#PRINCIPAL_NAME_ATTRIBUTE_NAME}.
*
* @author Rob Winch
*
* @param <S>
* the type of Session being managed by this
* {@link FindByPrincipalNameSessionRepository}
*/
public interface FindByPrincipalNameSessionRepository<S extends Session> extends SessionRepository<S> {
/**
* Find a Map of the session id to the {@link Session} of all sessions that
* contain the session attribute with the name
* {@link Session#PRINCIPAL_NAME_ATTRIBUTE_NAME} and the value of the
* specified principal name.
*
* @param principalName
* the principal name (i.e. username) to search for
* @return a Map (never null) of the session id to the {@link Session} of
* all sessions that contain the session attribute with the name
* {@link Session#PRINCIPAL_NAME_ATTRIBUTE_NAME} and the value of
* the specified principal name. If no results are found, an empty
* Map is returned.
*/
Map<String, S> findByPrincipalName(String principalName);
}

View File

@@ -26,6 +26,22 @@ import java.util.Set;
*/
public interface Session {
/**
* <p>
* A common session attribute that contains the current principal name (i.e.
* username).
* </p>
*
* <p>
* It is the responsibility of the developer to ensure the attribute
* is populated since Spring Session is not aware of the authentication
* mechanism being used.
* </p>
*
* @since 1.1
*/
String PRINCIPAL_NAME_ATTRIBUTE_NAME = Session.class.getName().concat(".PRINCIPAL_NAME_ATTRIBUTE_NAME");
/**
* Gets a unique string that identifies the {@link Session}
*

View File

@@ -35,6 +35,7 @@ import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.session.ExpiringSession;
import org.springframework.session.FindByPrincipalNameSessionRepository;
import org.springframework.session.MapSession;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
@@ -246,23 +247,28 @@ import org.springframework.util.Assert;
*
* @author Rob Winch
*/
public class RedisOperationsSessionRepository implements SessionRepository<RedisOperationsSessionRepository.RedisSession>, MessageListener {
public class RedisOperationsSessionRepository implements FindByPrincipalNameSessionRepository<RedisOperationsSessionRepository.RedisSession>, MessageListener {
private static final Log logger = LogFactory.getLog(SessionMessageListener.class);
/**
* The prefix for each key in Redis used by Spring Session
*/
static final String SPRING_SESSION_KEY_PREFIX = "spring:session:";
/**
* The prefix for each key that contains a mapping of the Principal name (i.e. username) to the session ids.
*/
static final String PRINCIPAL_NAME_PREFIX = SPRING_SESSION_KEY_PREFIX + "index:" + Session.PRINCIPAL_NAME_ATTRIBUTE_NAME + ":";
/**
* The prefix for SessionCreated event channel. The suffix is the session id.
*/
private static final String SPRING_SESSION_CREATED_PREFIX = "spring:session:event:created:";
private static final Log logger = LogFactory.getLog(SessionMessageListener.class);
private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() {
public void publishEvent(ApplicationEvent event) {
}
};
private static final String SPRING_SESSION_CREATED_PREFIX = SPRING_SESSION_KEY_PREFIX + "event:created:";
/**
* The prefix for each key of the Redis Hash representing a single session. The suffix is the unique session id.
*/
static final String BOUNDED_HASH_KEY_PREFIX = "spring:session:sessions:";
static final String BOUNDED_HASH_KEY_PREFIX = SPRING_SESSION_KEY_PREFIX + "sessions:";
/**
* The key in the Hash representing {@link org.springframework.session.ExpiringSession#getCreationTime()}
@@ -290,6 +296,11 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
private final RedisSessionExpirationPolicy expirationPolicy;
private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() {
public void publishEvent(ApplicationEvent event) {
}
};
/**
* If non-null, this value is used to override the default value for {@link RedisSession#setMaxInactiveIntervalInSeconds(int)}.
*/
@@ -357,6 +368,24 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
return getSession(id, false);
}
public Map<String,RedisSession> findByPrincipalName(String principalName) {
String principalKey = getPrincipalKey(principalName);
Set<Object> sessionIds = sessionRedisOperations.boundSetOps(principalKey).members();
Map<String,RedisSession> sessions = new HashMap<String,RedisSession>(sessionIds.size());
for(Object id : sessionIds) {
RedisSession session = getSession((String) id);
if(session != null) {
session.setLastAccessedTime(session.originalLastAccessTime);
sessions.put(session.getId(), session);
}
}
return sessions;
}
private String getPrincipalKey(String principalName) {
return PRINCIPAL_NAME_PREFIX + principalName;
}
/**
*
* @param id the session id
@@ -376,7 +405,7 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
return null;
}
RedisSession result = new RedisSession(loaded);
result.originalLastAccessTime = loaded.getLastAccessedTime() + TimeUnit.SECONDS.toMillis(loaded.getMaxInactiveIntervalInSeconds());
result.originalLastAccessTime = loaded.getLastAccessedTime();
result.setLastAccessedTime(System.currentTimeMillis());
return result;
}
@@ -459,6 +488,11 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
}
String principal = (String) session.getAttribute(Session.PRINCIPAL_NAME_ATTRIBUTE_NAME);
if(principal != null) {
sessionRedisOperations.boundSetOps(getPrincipalKey(principal)).remove(sessionId);
}
if(isDeleted) {
handleDeleted(sessionId, session);
} else {
@@ -640,9 +674,17 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
private void saveDelta() {
String sessionId = getId();
getSessionBoundHashOperations(sessionId).putAll(delta);
String key = getSessionAttrNameKey(Session.PRINCIPAL_NAME_ATTRIBUTE_NAME);
if(delta.containsKey(key)) {
Object principal = delta.get(key);
String principalKey = getPrincipalKey((String) principal);
sessionRedisOperations.boundSetOps(principalKey).add(sessionId);
}
delta = new HashMap<String,Object>(delta.size());
expirationPolicy.onExpirationUpdated(originalLastAccessTime, this);
Long originalExpiration = originalLastAccessTime == null ? null : originalLastAccessTime + TimeUnit.SECONDS.toMillis(getMaxInactiveIntervalInSeconds()) ;
expirationPolicy.onExpirationUpdated(originalExpiration, this);
}
}
}

View File

@@ -27,6 +27,7 @@ import static org.springframework.session.data.redis.RedisOperationsSessionRepos
import static org.springframework.session.data.redis.RedisOperationsSessionRepository.getSessionAttrNameKey;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
@@ -308,6 +309,46 @@ public class RedisOperationsSessionRepositoryTests {
assertThat(redisRepository.getSession(expiredId)).isNull();
}
@Test
public void findByPrincipalNameExpired() {
String expiredId = "expired-id";
when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations);
when(boundSetOperations.members()).thenReturn(Collections.<Object>singleton(expiredId));
when(redisOperations.boundHashOps(getKey(expiredId))).thenReturn(boundHashOperations);
Map map = map(
MAX_INACTIVE_ATTR, 1,
LAST_ACCESSED_ATTR, System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5));
when(boundHashOperations.entries()).thenReturn(map);
assertThat(redisRepository.findByPrincipalName("principal")).isEmpty();
}
@Test
public void findByPrincipalName() {
long lastAccessed = System.currentTimeMillis() - 10;
long createdTime = lastAccessed - 10;
int maxInactive = 3600;
String sessionId = "some-id";
when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations);
when(boundSetOperations.members()).thenReturn(Collections.<Object>singleton(sessionId));
when(redisOperations.boundHashOps(getKey(sessionId))).thenReturn(boundHashOperations);
Map map = map(
CREATION_TIME_ATTR, createdTime,
MAX_INACTIVE_ATTR, maxInactive,
LAST_ACCESSED_ATTR, lastAccessed);
when(boundHashOperations.entries()).thenReturn(map);
Map<String, RedisSession> sessionIdToSessions = redisRepository.findByPrincipalName("principal");
assertThat(sessionIdToSessions).hasSize(1);
RedisSession session = sessionIdToSessions.get(sessionId);
assertThat(session).isNotNull();
assertThat(session.getId()).isEqualTo(sessionId);
assertThat(session.getLastAccessedTime()).isEqualTo(lastAccessed);
assertThat(session.getMaxInactiveIntervalInSeconds()).isEqualTo(maxInactive);
assertThat(session.getCreationTime()).isEqualTo(createdTime);
}
@Test
public void cleanupExpiredSessions() {
String expiredId = "expired-id";