在 spring security + boot 控制 session 里面的用户信息
故事里始终都有爱
0x00 session 的概念简单描述
http 协议是无状态的, 每一次请求和上一次没有任何关系. 但是这样应付不了日益复杂的需求. 其中
cookie
和session机制
则是在客户端与服务器之间保持状态的解决方案。具体看下面这个例子
让我们用几个例子来描述一下cookie和session机制之间的区别与联系。笔者曾经常去的一家咖啡店有喝5杯咖啡免费赠一杯咖啡的优惠,然而一次性消费5杯咖啡的机会微乎其微,这时就需要某种方式来纪录某位顾客的消费数量。想象一下其实也无外乎下面的几种方案:
1、该店的店员很厉害,能记住每位顾客的消费数量,只要顾客一走进咖啡店,店员就知道该怎么对待了。这种做法就是协议本身支持状态。
2、发给顾客一张卡片,上面记录着消费的数量,一般还有个有效期限。每次消费时,如果顾客出示这张卡片,则此次消费就会与以前或以后的消费相联系起来。这种做法就是在客户端保持状态。
3、发给顾客一张会员卡,除了卡号之外什么信息也不纪录,每次消费时,如果顾客出示该卡片,则店员在店里的纪录本上找到这个卡号对应的纪录添加一些消费信息。这种做法就是在服务器端保持状态。
cookie机制采用的是在客户端保持状态的方案,而session机制采用的是在服务器端保持状态的方案
在谈论session机制的时候,常常听到这样一种误解“只要关闭浏览器,session就消失了”。其实可以想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对session来说也是一样的,除非程序通知服务器删除一个session,否则服务器会一直保留,程序一般都是在用户做log off的时候发个指令去删除session。然而浏览器从来不会主动在关闭之前通知服务器它将要关闭,因此服务器根本不会有机会知道浏览器已经关闭,之所以会有这种错觉,是大部分session机制都使用会话cookie来保存session id,而关闭浏览器后这个session id就消失了,再次连接服务器时也就无法找到原来的session。如果服务器设置的cookie被保存到硬盘上,或者使用某种手段改写浏览器发出的HTTP请求头,把原来的session id发送给服务器,则再次打开浏览器仍然能够找到原来的session。
恰恰是由于关闭浏览器不会导致session被删除,迫使服务器为seesion设置了一个失效时间(默认30min),当距离客户端上一次使用session的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把session删除以节省存储空间。
参考 Session机制详解
0x01 在 spring boot 里面使用 security 控制 session 的超时处理
- 在全局的 application.properties 里面配置超时时间
// 设置的超时的时间, 单位是的秒, 少于一分钟会自动设置为一分钟
server.session.timeout = 10
- 设置 WebSecurityConfigurerAdapter(也就是你的security的主配置类)
// 在 configure(HttpSecurity http) 方法里面加上
http.sessionManagement().invalidSessionUrl("/session/invalid")
// 同时将这个路径设置为 permitAll 权限
// 最终 session 失效了, 再访问需要权限的页面的话就会跳转到这个 uri
测试session过期的控制是否正确, 可以直接登录应用后, 将应用重启. 这样浏览器里面的 SEEESIONID 就失效了.
0x02 session 的并发控制(设置用户不能同时登录)
- 先将先前设置session过期时间设置的长一点
- 在security配置类里的配置方法里面加上两行代码, 然后变成下面这样
http.sessionManagement()
.invalidSessionUrl("/session/invalid")
.maximumSessions(1) // 设置相同用户同时登录的最大登录数
// 失效后会调用的一个方法
.expiredSessionStrategy(new DemoExpiredSessionStrategy())
.maxSessionsPreventsLogin(false) // 后登录的一个顶掉前面一个
.and()
/**
* DemoExpiredSessionStrategy 长这个样子. 当后面的顶掉先登录的用户时, 先登录的用户再访问应用就会调用的方法
*/
public class DemoExpiredSessionStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
HttpServletResponse response = event.getResponse();
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("并发登录导致session失效, 此方法被执行...");
}
}
/**
* 如果业务是不让后面的用户登录的话只要把.maxSessionsPreventsLogin(false) 改为 true 就可以了.
* 但是请注意, 此时禁止后面的用户登录 的逻辑在登录处理的过滤链上.
* 也就是后面用户在登录的时候会抛出一个 SessionAuthenticationException 然后终止此次登录.
* 抛出的异常可以被 AuthenticationFailureHandler 这个接口的实现类捕获. 可以自定义一些你期望的处理逻辑
* 注册的登录失败处理器 .failureHandler(authenticationFailureHandler) 放在 formLogin() 配置里
*/
.formLogin()
// 登录页面的访问路径 --> 自定义的 controller 方法来判断怎么跳转
.loginPage("/authentication/login")
.loginProcessingUrl("/login/authentication/image")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
// 同时 authenticationFailureHandler 长这个亚子
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
// 这个异常我只看到在超过最大设置数的时候抛出..
if (e instanceof SessionAuthenticationException) {
message = httpServletRequest.getParameter("username") + "帐户不允许同时在多台主机上登录。";
}
httpServletResponse.setContentType("text/html;charset=UTF-8");
httpServletResponse.getWriter().write(JSONObject.toJSONString(new Result<String>(false, message)));
}
}
0x03 session 的集群管理
- 场景
- pom.xml dependency
<!-- 引入spring session 依赖 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
<!-- spring session 支持的存储类型可以在 enum StoreType 里查看 -->
-
我们使用redis
- 因为 session 访问的很频繁, 每次登录的时候, 都会访问一次, 用nosql存储好一点
- redis存储的时候可以直接设置超时的时间, 比较方便
-
安装redis github上的下载地址
下载一个msi文件, 然后点击安装运行, 能打钩的全√上.
进入cmd 运行redis-cli
不报错就完成了.
只是简单的测试, 不需要别的, 这样配置就可以了. win 下安装会自动开启服务, 很简单 -
使用
在 application.properties 里面添加
spring.session.store-type = redis // 不区分大小写
启动spring boot就行了, 会自动配置好, 将session存在redis里面, 方便吧
注意所有要存在session里面的类型, 都要继承序列化接口, 因为现在session存在redis里面了, 不然会抛出这个错误.
启动之后, 登录服务器, 可以看到sessionID已经存进去了
redis 的使用例子
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(Application.class)
public class ApplicationTest {
private static final Logger LOG = Logger.getLogger(RedisApplicationTest.class);
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void testStringWithRedis(){
stringRedisTemplate.opsForValue().set("name", "miyahejuzi");
String val = stringRedisTemplate.opsForValue().get("name");
Assert.assertEquals("miyahejuzi", val);
}
}
以上.
<!--
security 退出登录
1. 让当前session 失效
2. 清楚与当前用户相关的remember-me记录
3. 清空当前的SecurityContext
4. 重定向到登录页
-->
<a href="/logout"></>
http.login()
.loginUrl("/logout")
// .logoutSuccessUrl("/index.html") 退出成功就会访问的路径
// 如果配置了handler 上面的就会失效了
.logoutSuccessHandler(logoutSuccessHandler)
public class ImoocLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("退出成功"));
// 可以在这里同时清空 cookie
}
}
- 我的 security 的 配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityBrowserConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 配置 验证码过滤器 在upaFilter之前
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
// 配置登录页面
.formLogin()
// 登录页面的访问路径 --> 自定义的 controller 方法来判断怎么跳转
.loginPage("/authentication/login")
.loginProcessingUrl("/login/authentication/image")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository())
.userDetailsService(userDetailsService)
// 也是可以配置的
.tokenValiditySeconds(60 * 60)
.and()
.sessionManagement()
.invalidSessionUrl("/session/invalid")
.maximumSessions(1)
.expiredSessionStrategy(new DemoExpiredSessionStrategy())
.maxSessionsPreventsLogin(true)
.and()
.and()
.authorizeRequests()
// 这几个请求不拦截
.antMatchers(getPermitAllUrl()).permitAll()
// 拦截其他请求
.anyRequest().authenticated()
.and()
.csrf()
.disable();
}
private String[] getPermitAllUrl() {
return new String[]{
"/authentication/*", "/login", "index", "/error",
"/code/*", "/session/invalid",
securityProperties.getBrowser().getLoginPage()
};
}
}