Spring Security已经为我们提供了完善的会话管理功能,包括会话固定攻击,会话超时检测以及会话并发控制。会话(session)就是无状态的HTTP实现用户状态可维持的一种解决方案。当用户首次访问系统时,系统会该用户生成一个sessionId,并添加到cookie中。在该用户的会话期内,每个请求都自动携带该cookie,因此系统可以很轻易地识别出这个来自那个用户的请求。
一:会话固定攻击
尽管cookie非常好用,但有时用户会在浏览器禁用它。为了解决这个问题,有些服务器还支持用URL重写的方式来实现类似的体验,例如:http://cyh.com/index.jsp;jsessionid=1pjztz08i2u4i,URL重写原本是为了兼容禁用cookie的浏览器而设计的,但却也容易被黑客利用,进而导致会话固定攻击。
会话固定攻击(session fixation attack)是利用应用系统在服务器的会话ID固定不变机制,借助他人用相同的会话ID获取认证和授权,然后利用该会话ID劫持他人的会话以成功冒充他人,造成会话固定攻击。
整个攻击流程是:
1、攻击者Attacker能正常访问该应用网站;
2、应用网站服务器返回一个会话ID给他;
3、攻击者Attacker用该会话ID构造一个该网站链接发给受害者Victim;
4-5、受害者Victim点击该链接,携带攻击者的会话ID和用户名密码正常登录了该网站,会话成功建立;
6、攻击者Attacker用该会话ID成功冒充并劫持了受害者Victim的会话。
但在Spring Security的HTTP防火墙会帮助我们拦截不合法的URL,当我们试图访问带Session的URL时,服务器会返回异常给用户。
也可以在Spring Security中配置会话管理来防御会话固定攻击,如果不配置Spring Security默认是使用migrateSession,栗子如下
http.authorizeRequests()
.antMatchers("/api/**").permitAll()
.antMatchers("/controller/**").authenticated()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.and()
.sessionManagement()
.sessionFixation().none();
sessionManagement是一个会话管理的配置器,其中,防御会话固定攻击的策略有四种,
none: 不做任何变动,登录过后仍然使用旧的session
newSession:登录之后创建一个新的session
migrateSession: 登录之后创建一个session,并将旧的session的数据复制到新的session里面 ---->默认
changeSessionId:不创建新的会话,而是使用由servlet容器提供的会话固定保护
二:会话过期
默认情况下,会话的过期时间是30分钟,即在30分钟内没有任何操作便会失效,在Spring Security中,当会话过期,我们可以设置他跳转到某一个地址,配置如下:
.sessionManagement()
.invalidSessionUrl("/login")
或者自定义过期策略,只要实现InvalidSessionStrategy接口,重写onInvalidSessionDetected方法即可,还需要配如下配置:
.sessionManagement()
.invalidSessionStrategy(你重写的实现类)
当然我们可以修改会话的过期时间。在spring boot中,会话过期时间最少为1分钟,即便设置小于60s也会被修正为1分钟。
server.servlet.session.timeout=60
二:会话并发控制
在Spring Security中,会话管理最完善的是会话并发控制,但会话并发控制存在一些用法的陷阱,这个等下说。
配置如下:
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;
@Autowired
MyUserDetailsServiceImpl myUserDetailsService;
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher(){
return new HttpSessionEventPublisher();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
http.authorizeRequests()
.antMatchers("/admin/api/**").hasAuthority("admin")
.antMatchers("/app/api/**").permitAll()
.antMatchers("/user/api/**").hasAuthority("user")
.anyRequest().authenticated()
.and().formLogin()
.loginPage("/myLogin.html")
.loginProcessingUrl("/login")
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.invalidateHttpSession(true)
.and()
.sessionManagement()
.maximumSessions(1) //最大会话数设置为1
.maxSessionsPreventsLogin(true); //阻止新会话登录,默认为false
http.csrf().disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
maximumSessions用于设置单个用户允许同时在线的最大会话数,如果没有额外设置,那么新登录的会话会踢掉旧的会话。如果需要在在会话数达到最大数时,阻止新会话建立,而不是踢掉旧的会话,则可以设置maxSessionsPreventsLogin为true。
我们通过将已登录的旧会话注销,访问/logout路径,然后尝试再次登录,发现登录不了了。这是因为Spring security是通过监听session的摧毁时间来触发会话信息表相关清理工作的,现在的问题点是缺乏事件源。但在spring Security中触发事件的类(HttpSessionEventPublisher)是有的,只是没有暴露出来,只需要把HttpSessionEventPublisher注册给IOC容器就可以,如下:
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher(){
return new HttpSessionEventPublisher();
}
上面我们是通过构建UserDetails来实现Spring Security所需的UserDetails,如果我们是通过自定义数据库结构,实现UserDetetails来是实现的话,就会出现问题,发现虽然我们做了会话的限制,但好像不起作用,不管多少个客户端都可以登录。
```php
public class User implements UserDetails {
private Long id;
private String username;
private String password;
private String roles;
private boolean enable;
private List<GrantedAuthority> authorities;
public String getRoles() {
return roles;
}
public void setRoles(String roles) {
this.roles = roles;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public boolean isEnable() {
return enable;
}
public void setEnable(boolean enable) {
this.enable = enable;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return this.enable;
}
public void setAuthorities(List<GrantedAuthority> authorities) {
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
Spring Security为了实现会话并发控制,采用会话信息表来管理用户的会话状态,具体实现如下:
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
// 存放用户以及其对应的所有sessionId的map
private final ConcurrentMap<Object, Set<String>> principals;
// 存放sessionId以及其对应的SessionInformation
private final Map<String, SessionInformation> sessionIds;
.........................
..................
}
principals中:
Object类似UserDetails的实现类
Set里面装的就是sessionId的集合
sessionIds中:
String就是sessionId
SessionInformation
通过查看源码发现,当有登录的时候,会触发spring的事件,然后onApplicationEvent会监听到,principals的compute方法是如果 key 对应的 value 不存在,则返回该 null,如果存在,则返回通过 remappingFunction 重新计算后的值。问题就出在在,因为hashMap如果存储对象的话,使用对象的引用地址,因为每个用户的地址都不一样,所以虽然我们做了会话限制,但还是可以登录多个用户。
此时,我们只要重写了User 的hashcode的值就可以把问题解决
@Override
public boolean equals(Object obj) {
return obj instanceof User ? this.username.equals(((User) obj).username) : false;
}
@Override
public int hashCode() {
return this.username.hashCode();
}