0 概述
《Spring Security实战》,这本书主要讲述授权与认证,以及相关的web安全知识,弥补了我这一部分知识的匮乏。
1 理论
授权与验证有一大堆的相关的安全知识
1.1 登录态
HTTP是无状态协议,无法有效地追踪用户的状态,无法有效地根据不同用户展示不同的页面。后来HTTP发展了Cookie和Session来解决这个问题。
1.1.1 Session
这是一个普通的表单登录
登录成功后,服务器返回一个Set-Cookie字段,关键在于JSESSIONID的这个Cookie
登录成功以后,之后的所有页面请求都会带有这个JSESSIONID的Cookie,服务器就能根据这个Cookie知道是哪个用户在访问当前页面了。注意,在上一次返回了JSESSIONID以后,下一次的页面就不再返回JSESSIONID了。
Set-Cookie: JSESSIONID=2C8346A1EBD87CB442F89DCD1E334BFA; Domain=test.com; Path=/; HttpOnly
注意,这个JSESSIONID的写法,是没有过期时间的。这种没有过期时间的Cookie称为Session,浏览器会在它退出的时候,自动删除这个Cookie。所以,我们下次登录的时候,服务器依然不知道我们是谁,就会要求我们再次输入账号与密码。
1.1.2 Cookie
我们平时登录的时候,都会看到一个“记住我”的按钮。点击以后,下次退出浏览器以后再登录,服务器也会知道我们是谁,我们不需要再次输入账号与密码。
Set-Cookie: JSESSIONID=2C8346A1EBD87CB442F89DCD1E334BFA; Domain=test.com; Max-Age=3600; Expires=Wed, 02-Jun-2021 11:48:58 GMT; Path=/; HttpOnly
一个直观的做法,是直接给JSESSIONID赋予过期时间,这样退出浏览器以后再次登录,浏览器依然会发送这个JSESSIONID的值。
但是,这样做会有个问题。服务器无法区分,当前用户是输入账号密码授权进来的,还是退出浏览器以后重新进来的。两者的安全性明显不同,我们需要对不同授权方式进来的用户给与不同的权限。
1.1.3 RememberMe
SpringSecurity的做法是,做两个Cookie,JSESSIONID依然是每次浏览器范围内的一个Cookie,然后有一个长期的remember-me的Cookie。
Set-Cookie: remember-me=ZHBlYlB5cEEzQnJScXBZYmlPcnJydyUzRCUzRDpETHBQMFVLRE91TWREU2phcmNOQzV3JTNEJTNE; Max-Age=3600; Expires=Wed, 02-Jun-2021 11:48:58 GMT; Domain=test.com; Path=/; HttpOnly
注意,remember-me的Cookie是有过期时间的,在退出浏览器以后,remember-me的Cookie不会自动删除。
那么,下次退出浏览器重新进入以后,JSESSIONID就没有了,但是remember-me的Cookie依然会继续发送给服务器
服务器就在验证了remember-me的Cookie的合法性以后,会续期remember-me的Cookie,并且分配新的JSESSIONID。这个过程用户是没有感知的,他看到的是退出浏览器重新进入后依然保持了登录态。同时,服务器知道这个用户是从remember-me进来的,而不是通过输入账号密码进来的。
1.1.4 展开
其实JSESSIONID与remember-me的关系就像,AccessToken与RefreshToken的关系。AccessToken代表你的凭证,RefreshToken是可以后备保存来刷新凭证。AccessToken的有效期比较短,所以即使被盗也是影响有限,RefreshToken是存放在本地的Token,很少在网络上传输的,安全性会更强。
但是由于浏览器的关系,remember-me的Cookie是每次都会在网络上传输,它的安全性相比RefreshToken就差很多了。
1.2 会话锁定攻击
会话固定攻击,看这里
会话固定攻击常用于在Url中传递JSESSIONID。
- 攻击者自己登录网站,记下自己的JSESSIONID
- 然后构造一个新的url,url的参数上附带自己的JSESSIONID,把这个url发送给别人
- 被攻击者点击url以后登录,登录态被记录在当前的JSESSIONID
- 攻击者就能沿用同一个JSESSIONID窃取了别人的登录态
因此,我们的防御方式是:
- 关闭透明Session,禁止在Url上附带JSESSIONID
- 每次登录前后,JSESSIONID都需要重新生成
- JSESSIONID需要以尽量随机的,不易被猜测的方式生成。
1.3 CSRF攻击与HttpOnly
1.3.1 原因
CSRF攻击,看这里
他的攻击在于,在A网站,可以发送到B网站的跨域请求,但是这个请求只能发送,不能获取结果的。在普通的浏览器上,跨域请求都会附带跨域的Cookie,例如A网站请求B网站的请求时,该请求附带B网站的Cookie,并且会被B网站的服务器处理。浏览器仅仅是不允许A网站读取该跨域请求的返回结果而已。
请求会到达B网站服务器
但是,A网站在请求完毕后,无法读取请求的返回结果,浏览器会报出statusCode为0的错误。
但是,尽管如此,该跨域请求确实附带了B网站的Cookie,也被B网站的服务器处理了,这样最终导致CSRF攻击。你想想你在一个A网站浏览的时候,发现自己登录过的其他银行网站被突然转账了是一件多可怕的事情。
1.3.2 Origin防御
一个简单的防御是,服务器B检查,请求的来源,Origin与Referer是不是自己的网站,不是的话就直接拒绝。但是这个方法比较少用,因为Referer在不同浏览器上的实现不同,而且,Referer涉及到用户隐私,用户可以在浏览器中设置请求中不提供Referer属性。
1.3.3 Session防御
CSRF攻击与普通请求的关键在于,CSRF攻击总是跨域的。那么跨域请求与普通请求的最大区别在于:
- 跨域请求无法读取请求的返回值
- 跨域请求无法在javascript脚本读取跨域的Cookie
因此,我们根据这两个不同的构造出CSRF的防御措施。
<form action="xxx">
<input type="text" name="name"/>
<input type="password" name="password"/>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
<button type="submit">登录</button>
</form>
我们在获取登录页面,和任意的表单页面上。用session生成一个随机的csrfToken值,然后注入到表单的一个隐藏字段上。提交表单的时候,这个字段会自动提交到后端,然后校对与当前后端session的值是否一致,就知道csrf攻击有没有发生了。
当跨域csrf发生的时候,它无法获取表单的隐藏csrf字段,所以会被后端的服务器拦截,拒绝执行请求。
@Data
@AllArgsConstructor
public static class ResponseResult{
@JsonIgnore
private HttpStatus statusCode;
private int code;
private String msg;
private Object data;
private String csrfToken;
private void init(HttpStatus httpStatus,int code,String message,Object data){
this.code = code;
this.msg = message;
this.data = data;
this.statusCode = httpStatus;
= RequestContextHolder.currentRequestAttributes();
RequestAttributes requestAttributes = ((ServletRequestAttributes)requestAttributes).getRequest();
HttpServletRequest request Enumeration<String> days = request.getAttributeNames();
while( days.hasMoreElements()){
.info("attributes {}",days.nextElement());
log}
= (CsrfToken)request.getAttribute("org.springframework.security.web.csrf.CsrfToken");
CsrfToken token if( token != null ){
this.csrfToken = token.getToken();
}
}
public ResponseResult(int code,String message,Object data){
init(HttpStatus.OK,code,message,data);
//throw new RuntimeException("123");
}
public ResponseResult(HttpStatus httpStatus,int code,String message,Object data){
init(httpStatus,code,message,data);
}
}
另外一种方法,是在前后端分离的网站中,返回结构体增加一个csrfToken字段。前端收到这个字段后存放到本地变量,然后每次提交请求到附带这个字段就可以了。
1.3.4 Cookie防御
另外一种方法,是后端构造一个XSRF-Token值,写入到Cookie返回到前端。注意,因为POST请求需要XSRF-Token值,所以在调用POST请求之前,你需要保证用GET请求拿到页面的XSRF-Token值。
function getCookie(name){
var strcookie = document.cookie;//获取cookie字符串
var arrcookie = strcookie.split("; ");//分割
//遍历匹配
for ( var i = 0; i < arrcookie.length; i++) {
var arr = arrcookie[i].split("=");
if (arr[0] == name){
return arr[1];
}
}return "";
}
.headers = {
newOptions'Content-Type': 'application/x-www-form-urlencoded',
'X-XSRF-TOKEN':getCookie('XSRF-TOKEN'),
...newOptions.headers,
; }
前端用js读取到这个XSRF-Token值并附带到Header里面提交。注意,这个方法里面,HttpOnly必须false,表示允许被js读取到这个Cookie。
由于跨域请求,无法跨域读取cookie,所以会被后端的服务器拦截,拒绝执行请求。我们在请求的时候,需要写入两个参数:
- X-XSRF-TOKEN的header值
- Cookie里面的JESSIONID和XSRF-Token值
1.4 请求跨域与CORS
我们在CSRF那一节了解过,普通跨域请求只能请求,不能获取返回数据。但是,如果我们的网址是www.abc.com,我们需要向后端的api.abc.com请求数据的话,这样就会出问题的。这样的API设计在当前的前后端分离项目中十分常见,怎么办?答案是使用CORS机制。
1.4.1 前端CORS
const newOptions = {
...xxx
mode:"cors",
;
}let response = await fetch(url, newOptions);
前端用fetch发送CORS请求相当简单,只需要在原来的基础上加入mode:cors就可以了。
开启CORS请求以后,前端对API的请求,会先用OPTIONS方法调用检查跨域服务器是否允许这个请求,然后才会执行实际的POST方法来实际请求。
在OPTIONS预检请求中,服务器会发送Access-Control-Allow-Headers,Access-Control-Allow-Methods,Access-Control-Allow-Origin来响应允许这样的请求。
1.4.2 后端CORS
Access-Control-Allow-Origin: http://client.test.com
后端处理CORS,需要两步:
- 拦截OPTIONS请求,然后在OPTIONS请求的返回中加入合适的Access-Control-Allow-Headers,Access-Control-Allow-Methods,Access-Control-Allow-Origin的Header。如果服务器检查了Origin字段,发现该请求是不合法的,那么就返回一个不含以上Header的回复,那么浏览器就会拒绝这个CORS请求。
- 在正常的POST请求的返回中加入Access-Control-Allow-Origin的Header。
1.4.3 后端允许Cookie传送
CORS在默认情况下,是不会附带Cookie的信息。服务器需要在OPTIONS预检请求中加入Access-Control-Allow-Credentials:true的Header,那么在接下来的POST请求中才会附带Cookie信息。
**这里有个坑,如果服务器设置了Access-Control-Allow-Origin:*,那么就不能设置Access-Control-Allow-Credentials:true**。所以,你必须让Access-Control-Allow-Origin设置为一个特指的Host,而不能设置泛指的星号,才能设置Access-Control-Allow-Credentials:true。
1.5 Cookie跨域与SameSite
CORS解决了跨域请求的问题,然后我们加上Access-Control-Allow-Credentials:true也会让Cookie一起附带上进行跨域请求。但是在Chrome 80以上的版本,这样做依然不行,即使打开了Access-Control-Allow-Credentials:true,跨域请求依然没有附带上Cookie。这是因为Cookie新增了一个叫SameSite的字段。
SameSite字段的出发点是站在浏览器的角度来避免CSRF攻击,在Chrome新版本上,所有Cookie在没有设置SameSite字段的情况下,就会默认SameSite=Lax的值。SameSite的意义是,当用户处于A域名网站的时候,触发对B域名的跨域请求时,浏览器不会附带B域名的Cookie,毕竟CSRF攻击就是因为跨域请求时附带了Cookie导致的。
但是,在特殊的场景下,例如,我们在www.abc.com的网站,对api.abc.com的域名进行请求时,如果浏览器不对api.abc.com的域名附带Cookie,就会导致服务器无法区分请求者是哪个用户。换句话说,CORS仅仅是保证了我们可以在跨域的情况下获取请求的回复,但无法保证我们在跨域的情况需要附带Cookie的问题。
1.5.1 根域名同域
如果在client.test.com网站请求对server.test.com的域名,由于两者都是同一个根域名test.com。那么解决方法就可以很简单,让server.test.com的回复,让Cookie设置Domain为test.com。
那么虽然这个server.test.com的请求是跨域请求,但Cookie不是跨域的,依然会被附带在server.test.com的请求上,从而绕过了SameSite的限制。
1.5.2 根域名不同域
但是,如果我们在testclient.com网站请求对testserver.com的域名,两者是不属于同一个根域名的,这个Cookie就会因为默认的SameSite设置被拦截,无法在跨域请求中附带出去。所以,这个时候,我们只能对JESESSIONID进行特殊设置,将SameSite设置为None,并且Secure也要打开。这个时候,JESESSIONID就会在下次的跨域请求附带上了。
1.5.3 根域名不同域的CSRF防御
当然,JESSIONID没有了SameSite的保护,那么任何对testserver.com请求,即使不是CORS请求,也会附带上这个JESSIONID,这显然会导致CSRF攻击。
我们之前讨论的CSRF防御都是基于,网站域名与API域名都是同域的情况,而目前是不同域的情况,该如何防御CSRF攻击呢?
解决方法是让API请求从接口处返回CSRFToken
然后让这个CSRFToken附带在Header上来提交。我们模拟一下情况:
- 对于可信网站,由于网站能通过CORS验证,所以,他能在HTTP回复获取到这个CSRFToken,因此请求成功。
- 对于不可信网站,当它发出CORS请求时,由于它不能通过CORS验证,所以,他不能在HTTP回复获取到这个CSRFToken,因此请求失败。如果它直接发非CORS请求,它的Header没有CSRFToken,所以请求也会失败。
另外一种情况,我们用1.3.4的Cookie防御方式,模拟一下情况:
- 对于可信网站,CSRFToken通过Cookie返回过来,但是这个Cookie是在testserver.com域名下的,前端js在testclient.com域名下的,无法直接读取到这个Cookie,更无法附带在请求的Header上,所以,请求失败。
1.6 单点登录
单点登录,注意与CORS的区分。CORS解决的是跨域请求的问题,Cookie是依然是跨域上。单点登录是,在A域名上登录,但是在B域名下可以感知到这个登录态。
其实,CAS单点登录的原理简单。它的方式是在A域名登录后,A域名跳转到B域名的固定网址上,并附带着一个特殊的code字段。B域名服务器根据这个code字段到A域名服务器查询登录态和用户信息,然后写入到自己的Cookie和Session上就可以完成自己域名下登录态的写入。
这个过程其实和OAuth是十分相似的。
2 表单登录
代码在这里
2.1 UserDetail
package spring_test.framework;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring_test.business.User;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Created by fish on 2021/4/26.
*/
//UserDetail需要序列化的,要确保每个字段都可以被序列化.
public class MyUserDetail implements UserDetails {
private static final long serialVersionUID = 4359709211352400087L;
private String name;
private String password;
private Long userId;
private String role;
public MyUserDetail(User user){
this.name = user.getName();
this.password = user.getPassword();
this.userId = user.getId();
this.role = user.getRole().toString();
}
public Collection<? extends GrantedAuthority> getAuthorities(){
List<GrantedAuthority> authorities = new ArrayList<>();
.add(new SimpleGrantedAuthority(this.role));
authoritiesreturn authorities;
}
public Long getUserId(){
return this.userId;
}
public String getPassword(){
return this.password;
}
public String getUsername(){
return this.name;
}
public boolean isAccountNonExpired(){
return true;
}
public boolean isAccountNonLocked(){
return true;
}
public boolean isCredentialsNonExpired(){
return true;
}
public boolean isEnabled(){
return true;
}
@Override
public boolean equals(Object obj){
if( obj instanceof MyUserDetail ){
return this.getUsername().equals(((MyUserDetail) obj).getUsername());
}else{
return false;
}
}
@Override
public int hashCode(){
return this.getUsername().hashCode();
}
}
首先定义一个UserDetail
package spring_test.framework;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import spring_test.business.User;
import spring_test.infrastructure.UserRepository;
/**
* Created by fish on 2021/4/26.
*/
@Component
@Slf4j
public class MyUserDetailService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
= userRepository.getByNameForRead(username);
User user if( user == null){
//这个异常是固定的,不能改其他的
throw new UsernameNotFoundException("用户不存在");
}
return new MyUserDetail(user);
}
}
然后定义一个UserDetailService
2.2 PasswordEncoder
/**
* Created by fish on 2021/4/26.
*/
@Configuration
//这里可以打开debug模式
//可以看到请求过来的Request Cookie与Body
//在POSTMAN中,GET请求仅需要设置Cookie字段就可以访问了
//POST请求需要额外添加X-XSRF-TOKEN的header
@EnableWebSecurity(debug=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//密码编码器
@Bean
public PasswordEncoder passwordEncoder(){
= new BCryptPasswordEncoder(12);
PasswordEncoder encoder return encoder;
}
@Autowired
private MyUserDetailService myUserDetailService;
@Bean
public UserDetailsService userDetailsService(){
return myUserDetailService;
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
.userDetailsService(myUserDetailService)
auth.passwordEncoder(passwordEncoder());
}
}
定义密码编码器,并且我们将UserDetailService与PasswordEncoder通过configure配置进去
2.3 登录与登出
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
= new JdbcTokenRepositoryImpl();
JdbcTokenRepositoryImpl jdbcTokenRepository .setDataSource(dataSource);
jdbcTokenRepository
.
http//开启csrf
csrf()
.disable()
.and()
//设置认证异常与授权异常的处理
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.and()
//表单登录的处理
//必须要用urlEncode的参数来传入
.formLogin()
.permitAll()
.loginProcessingUrl("/login/login")
.usernameParameter("user")//登录的用户名字段名称
.passwordParameter("password")//登录的密码字段名称
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
.and()
//登出的处理
.logout()
.permitAll()
.logoutUrl("/login/logout")
.logoutSuccessHandler(logoutSuccessHandler);
.authorizeRequests()
http.antMatchers("/login/login").permitAll()
.antMatchers("/login/islogin").permitAll()
.anyRequest().authenticated();
}
}
然后我们在configure(HttpSecurity http)配置表单登录的信息,包括登录的url,用户名字段,密码字段,以及登出字段。通过.successHandler,.failureHandler和.logoutSuccessHandler,我们可以重写登录和登出时成功的回复。
package spring_test.framework;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import java.io.IOException;
import java.io.PrintWriter;
//授权入口,发现用户未登陆,或者授权的权限不足的情况
@Component
public class HttpAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
= new ObjectMapper();
ObjectMapper mapper
@Override
public void commence(HttpServletRequest request,
,
HttpServletResponse responseAuthenticationException authException) throws IOException, ServletException{
.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response
String result = mapper.writeValueAsString(new MyResponseBodyAdvice.ResponseResult(1,"未登录",null));
PrintWriter writer = response.getWriter();
.write(result);
writer.flush();
writer}
}
authenticationEntryPoint是用户未授权的情况时,被SpringSecurity阻止的逻辑
package spring_test.framework;
import org.apache.catalina.servlet4preview.http.HttpServletRequest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* Created by fish on 2021/4/26.
*/
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(javax.servlet.http.HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException{
.setStatus(403);
response.getWriter().write("Forbidden:" + accessDeniedException.getMessage());
response}
}
AccessDeniedHandler是用户已经授权,但是角色权限不足的情况时,被SpringSecurity阻止的逻辑。
3 记住我
3.1 配置
代码在这里
@Override
protected void configure(HttpSecurity http) throws Exception {
= new JdbcTokenRepositoryImpl();
JdbcTokenRepositoryImpl jdbcTokenRepository .setDataSource(dataSource);
jdbcTokenRepository
.
httpcsrf()
.disable()
//设置认证异常与授权异常的处理
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.and()
//表单登录的处理
//必须要用urlEncode的参数来传入
.formLogin()
.permitAll()
.loginProcessingUrl("/login/login")
.usernameParameter("user")//登录的用户名字段名称
.passwordParameter("password")//登录的密码字段名称
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
.and()
//记住我,必须用check-box传入一个remeber-me的字段
//使用记住我以后,maximumSessions为1是没有意义的,因为他能被自动登录
.rememberMe()
.userDetailsService(myUserDetailService)
.tokenRepository(jdbcTokenRepository)
.tokenValiditySeconds(60 * 60);
}
之前说过了,rememberMe就是相当于refreshToken的作用,可以避免退出浏览器重新进入后,需要重新输入账号密码登录的问题。我们可以在这里rememberMe的有效时间,以及rememberMe的存储位置。
3.2 散列加密与持久化令牌
rememberMe的构造有两种方式:
- 散列加密,后台对当前用户ID,以及特殊的key值做hash,一起和用户ID合并起来的字符串,特殊的key值是为了防止前端自己伪造rememberMe的Token。具体看P60
- 持久化令牌,在数据库存储用户ID,和随机的Token值的映射。服务器可以通过检查数据库来确定这个Token是哪个用户。具体看P64
散列加密的方案优点在不需要存储,但是有相对应的缺点:
- 一个Token,可以放到多个PC上进行登录,这导致了无法限制登录数量的问题。
- 一旦rememberMe的Token被盗取了,服务器没有办法去发现它。在Token的有效时间内,盗取者都可以用被盗的身份来获取合法的JESESSIONID。
持久化令牌的方案缺点是需要存储,但是每个Token都不会依赖key,纯粹的一个随机数而已,它能很好地避免了散列方案的问题。因为每个Token在换取一个新的JESESSIONID后就会变更,数据库中对于一个用户只能对应一条Token值。这使得:
- 一个Token,只能在单个PC上登录。因为一个Token换取一个新的JESESSIONID后,就会在数据库中变成新的Token。旧的Token无法在另外一台PC上继续换取JESESSIONID。
- 可以一定程度上的防盗。如果Token泄漏了,在盗取者机器A上换取了JESESSIONID。而原用户就会在不知情的情况下继续用旧Token换取JESESSIONID,SpringSecurity就会将该用户所有的JESESSIONID都删除掉,并将所有Token删除,以迫使该用户只能以账号密码的方式登录。但是,如果Token泄露了,原用户不登录的话,这种风险依然是存在的,后台是无法发现的。
这里的逻辑比较严谨巧妙,具体看P64
4 会话管理
4.1 会话过期
# session的默认保留时间, SpringBoot 1.x的配置
spring.session.timeout = 1h
# session的默认保留时间, SpringBoot 2.x的配置
server.servlet.session.timeout = 1h
我们可以在application.properties设置会话的保留时间,如果60秒以内会话没活动,就会自动失效。注意,这个值最小为60秒,即使设置少于60秒,SpringSecurity也会校正为60秒。P79
4.2 会话并发
代码在这里
@Override
protected void configure(HttpSecurity http) throws Exception {
= new JdbcTokenRepositoryImpl();
JdbcTokenRepositoryImpl jdbcTokenRepository .setDataSource(dataSource);
jdbcTokenRepository
.
http//开启csrf
csrf()
//默认的headerName为"X-XSRF-TOKEN";
//默认的cookieName为"XSRF-TOKEN";
//默认的parameterName的"_csrf";
//可以看一下CookieCsrfTokenRepository的源代码
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
//设置认证异常与授权异常的处理
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.and()
//表单登录的处理
//必须要用urlEncode的参数来传入
.formLogin()
.permitAll()
.loginProcessingUrl("/login/login")
.usernameParameter("user")//登录的用户名字段名称
.passwordParameter("password")//登录的密码字段名称
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
.and()
//记住我,必须用check-box传入一个remeber-me的字段
//使用记住我以后,maximumSessions为1是没有意义的,因为他能被自动登录
.rememberMe()
.userDetailsService(myUserDetailService)
.tokenRepository(jdbcTokenRepository)
.tokenValiditySeconds(60 * 60)
.and()
//登出的处理
.logout()
.permitAll()
.logoutUrl("/login/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.and()
//单个用户的最大可在线的会话数
.sessionManagement()
//.invalidSessionStrategy(invalidSessionStrategy)
.maximumSessions(1)
.expiredSessionStrategy(sessionInformationExpiredStrategy);
.authorizeRequests()
http.antMatchers("/login/login").permitAll()
.antMatchers("/login/islogin").permitAll()
.anyRequest().authenticated();
}
SpringSecurity还提供了每个用户的会话并发限制,就像上面的配置,就是每个用户同时在线的会话只能为1个。当会话到达上限后,就会踢掉该用户的旧会话下线。
.sessionManagement()
//.invalidSessionStrategy(invalidSessionStrategy)
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.expiredSessionStrategy(sessionInformationExpiredStrategy);
我们也可以设置,当会话到达上限后,就会阻止该用户的新会话创建。P82
public class SessionRegistryImpl implements SessionRegistry{
//存放用户以及对应的所有的sessionId的map
private final ConcurrentMap<Object,Set<String>> principals;
private final Map<String,SessionInformation> sessionIds;
//对新会话创建时
public void registerNewSession(String sessionId,Object principal){
this.sessionIds.put(sessionId,new SessionInformation(principal,sessionId,new Date()));
Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);
xxxx
.add(sessionId);
sessionsUsedByPrincipal}
}
SessionRegistryImpl是会话并发控制的核心源码,在P90,它是由ConcurrentSessionControlAuthenticationStarategy的onAuthentication触发的,当新的会话产生时就触发SessionRegistryImpl的registerNewSession方法。它会在内存中记录对应的principals和sessionIds。
从代码中可以看出,principals的Map是以principal为Key的。而principal正正就是UserDetail。
package spring_test.framework;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring_test.business.User;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Created by fish on 2021/4/26.
*/
//UserDetail需要序列化的,要确保每个字段都可以被序列化.
public class MyUserDetail implements UserDetails {
private static final long serialVersionUID = 4359709211352400087L;
private String name;
private String password;
private Long userId;
private String role;
public MyUserDetail(User user){
this.name = user.getName();
this.password = user.getPassword();
this.userId = user.getId();
this.role = user.getRole().toString();
}
public Collection<? extends GrantedAuthority> getAuthorities(){
List<GrantedAuthority> authorities = new ArrayList<>();
.add(new SimpleGrantedAuthority(this.role));
authoritiesreturn authorities;
}
public Long getUserId(){
return this.userId;
}
public String getPassword(){
return this.password;
}
public String getUsername(){
return this.name;
}
public boolean isAccountNonExpired(){
return true;
}
public boolean isAccountNonLocked(){
return true;
}
public boolean isCredentialsNonExpired(){
return true;
}
public boolean isEnabled(){
return true;
}
@Override
public boolean equals(Object obj){
if( obj instanceof MyUserDetail ){
return this.getUsername().equals(((MyUserDetail) obj).getUsername());
}else{
return false;
}
}
@Override
public int hashCode(){
return this.getUsername().hashCode();
}
}
所以,要让会话并发控制有效,就需要对UserDetail重写equals与hashCode方法了。P92
package spring_test.framework;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* Created by fish on 2021/4/27.
*/
@Component
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
@Autowired
= new ObjectMapper();
ObjectMapper mapper
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException{
= event.getResponse();
HttpServletResponse response = event.getRequest();
HttpServletRequest request
//清空remember-me的cookie
[] cookies = request.getCookies();
Cookiefor (Cookie cookie : cookies) {
if (cookie.getName().contains("remember-me")) {
String cookieName = cookie.getName();
= new Cookie(cookieName, null);
Cookie newCookie .setPath("/");
newCookie.addCookie(newCookie);
responsebreak;
}
}
//返回登录过期了
.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
responseString result = mapper.writeValueAsString(new MyResponseBodyAdvice.ResponseResult(20001,"你的账号在其他地方登录了",null));
PrintWriter writer = response.getWriter();
.write(result);
writer.flush();
writer}
}
最后,我们可以重写当踢掉旧会话上线时的处理逻辑,MySessionInformationExpiredStrategy。这里要注意删除remember-me的Cookie,这样才会让用户避免再次上线。如果不删除remember-me的Cookie,用户只要再刷新一次页面就能进入了。
4.3 会话存储
默认情况下,会话存放在内存中,当应用重新启动的时候,会话都会丢失。
但是,在IDEA下,直接restart应用,会话会自动保存重放,这样能避免开发环境下需要不断登录。同时,有时候IDEA这样做会给你带来困惑,让你怀疑会话不是存放在内存中的。具体看这里和这里
4.4 集群会话
.sessionManagement()
http.maximumSessions(1)
.sessionRegistry(redisSessionRegistry)
.expiredSessionStrategy(sessionInformationExpiredStrategy);
我们只需要设置sessionRegistry就可以了,这里参考书本的P97。
这个需要引用额外的Spring Session库,用来管理额外容器的Session。
5 验证码
代码在这里
package spring_test;
/**
* Created by fish on 2021/6/3.
*/
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import org.apache.catalina.servlet4preview.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
@RestController
public class CaptchaController {
@ResponseBody
@RequestMapping("/captcha")
public String captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
= new SpecCaptcha(130, 48, 5);
SpecCaptcha specCaptcha .setCharType(Captcha.TYPE_NUM_AND_UPPER);
specCaptchaString verCode = specCaptcha.text().toLowerCase();
//将结果写入到session中
.getSession().setAttribute("captcha",verCode);
requestreturn specCaptcha.toBase64();
}
}
先建立一个captcha的接口,前端请求该接口以后,将captcha的答案写入session。
package spring_test.framework;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.security.core.AuthenticationException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
/**
* Created by fish on 2021/6/3.
*/
@Component
@Slf4j
public class VerificationCodeFilter extends OncePerRequestFilter {
@Autowired
private AuthFailureHandler authFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException{
if( !"/login/login".equals(httpServletRequest.getRequestURI())){
.doFilter(httpServletRequest,httpServletResponse);
filterChain}else{
try {
vertifyCode(httpServletRequest, httpServletResponse);
.doFilter(httpServletRequest, httpServletResponse);
filterChain}catch(AuthenticationException e ){
.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
authFailureHandler}
}
}
public void vertifyCode(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)throws AuthenticationException{
String requestCode = httpServletRequest.getParameter("captcha");
= httpServletRequest.getSession();
HttpSession session String savedCode = (String)session.getAttribute("captcha");
//清除验证码,只有一次尝试机会
.removeAttribute("captcha");
session
if( StringUtils.isEmpty(requestCode) || StringUtils.isEmpty(savedCode) || ! requestCode.equals(savedCode)){
throw new VertificationCodeException();
}
}
}
然后我们建立一个Filter,注意Filter是继承自OncePerRequestFilter。OncePerRequestFilter的意思是每个请求仅经过此Filter一次,这是Spring里面的推荐使用的Filter。该Filter的意图也简单,就是在login/login接口里面,检查传入captcha与实际的答案是否相符。注意,无论答对与否,captcha只能使用一次,每次都要清空答案。
@Override
protected void configure(HttpSecurity http) throws Exception {
....
http.addFilterBefore(verificationCodeFilter, UsernamePasswordAuthenticationFilter.class);
}
最后,我们指定该filter在UsernamePasswordAuthenticationFilter之前触发。
@Controller
public class CaptchaController {
@Autowired
private RedisUtil redisUtil;
@ResponseBody
@RequestMapping("/captcha")
public JsonResult captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
= new SpecCaptcha(130, 48, 5);
SpecCaptcha specCaptcha String verCode = specCaptcha.text().toLowerCase();
String key = UUID.randomUUID().toString();
// 存入redis并设置过期时间为30分钟
.setEx(key, verCode, 30, TimeUnit.MINUTES);
redisUtil// 将key和base64返回给前端
return JsonResult.ok().put("key", key).put("image", specCaptcha.toBase64());
}
@ResponseBody
@PostMapping("/login")
public JsonResult login(String username,String password,String verCode,String verKey){
// 获取redis中的验证码
String redisCode = redisUtil.get(verKey);
// 判断验证码
if (verCode==null || !redisCode.equals(verCode.trim().toLowerCase())) {
return JsonResult.error("验证码不正确");
}
}
}
对于某些没有使用Session作为登录态的前后端分离项目,EasyCaptcha推荐使用Redis作为存储答案的方式,但是要注意,该代码忘了清空答案。
6 CSRF
代码在这里
@Override
protected void configure(HttpSecurity http) throws Exception {
= new JdbcTokenRepositoryImpl();
JdbcTokenRepositoryImpl jdbcTokenRepository .setDataSource(dataSource);
jdbcTokenRepository
.
http//开启csrf
csrf()
//默认的headerName为"X-XSRF-TOKEN";
//默认的cookieName为"XSRF-TOKEN";
//默认的parameterName的"_csrf";
//可以看一下CookieCsrfTokenRepository的源代码
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
xxx}
打开csrf配置,使用Cookie防御的方式。
function getCookie(name){
var strcookie = document.cookie;//获取cookie字符串
var arrcookie = strcookie.split("; ");//分割
//遍历匹配
for ( var i = 0; i < arrcookie.length; i++) {
var arr = arrcookie[i].split("=");
if (arr[0] == name){
return arr[1];
}
}return "";
}
export default async function request(url, options) {
const newOptions = {
...defaultOptions,
...options,
headers:{
'X-XSRF-TOKEN':getCookie('XSRF-TOKEN')
,
}query:{
_t:new Date().valueOf(),
...options.query,
};
}
let response = await fetch(url, newOptions);
}
然后我们在Header的位置加入X-XSRF-TOKEN,这个值是来源于Cookie的。
7 同根域CORS
代码在这里
我们设计一个在client.test.com网站向server.test.com域名的跨域登录请求。
7.1 DNS
127.0.0.1 client.test.com
127.0.0.1 server.test.com
编辑/etc/hosts,加入这两条
7.2 nginx
server{
listen 80;
server_name server.test.com;
location / {
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_pass http://localhost:9595;
}
}
加入TestServer配置
server{
listen 80;
server_name client.test.com;
location / {
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_pass http://localhost:9596;
}
}
加入TestClient配置
7.3 客户端CORS
function getCookie(name){
var strcookie = document.cookie;//获取cookie字符串
var arrcookie = strcookie.split("; ");//分割
//遍历匹配
for ( var i = 0; i < arrcookie.length; i++) {
var arr = arrcookie[i].split("=");
if (arr[0] == name){
return arr[1];
}
}return "";
}
export default async function request(url, options) {
const newOptions = {
...defaultOptions,
...options,
mode:"cors",
headers:{
'X-XSRF-TOKEN':getCookie('XSRF-TOKEN')
,
}query:{
_t:new Date().valueOf(),
...options.query,
};
}
xxxx= "http://server.test.com"+url;
url
let response = await fetch(url, newOptions);
}
前端文件中加入mode选项,CSRF防御依然使用Cookie防御的方式。
7.4 服务器CORS
package spring_test;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.util.Date;
/**
* Created by fish on 2021/6/2.
*/
public class MyCookieProcessor extends Rfc6265CookieProcessor {
public String generateHeader(javax.servlet.http.Cookie cookie, HttpServletRequest request) {
.setDomain("test.com");
cookiereturn super.generateHeader(cookie,request);
}
}
先定义一个CookieProcessor,以让所有的Cookie都设置test.com的Domain
package spring_test;
import org.apache.tomcat.util.http.LegacyCookieProcessor;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Created by fish on 2021/6/1.
*/
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
@Bean
public TomcatContextCustomizer sameSiteCookiesConfig() {
return context -> {
final MyCookieProcessor cookieProcessor = new MyCookieProcessor();
.setCookieProcessor(cookieProcessor);
context};
}
}
将MyCookieProcessor注入到MvcConfiguration中。
@Override
protected void configure(HttpSecurity http) throws Exception {
xxxxx
.authorizeRequests()
http.antMatchers("/login/login").permitAll()
.antMatchers("/login/islogin").permitAll()
.anyRequest().authenticated()
.and()
.cors();
}
@Bean
corsConfigurationSource(){
CorsConfigurationSource = new CorsConfiguration();
CorsConfiguration configuration .setAllowedOrigins(Arrays.asList("https://client.test.com","http://client.test.com"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration
= new UrlBasedCorsConfigurationSource();
UrlBasedCorsConfigurationSource source .registerCorsConfiguration("/**",configuration);
sourcereturn source;
}
然后在WebSecurityConfigurerAdapter中加入以上配置即可。注意,setAllowedOrigins是不可以设置localhost域名,而且http协议还是https协议必须明确写出来,不能省略。
登录http://client.test.com/index.html即可
8 不同根域CORS
代码在这里
我们设计一个在testclient.com网站向testserver.com域名的跨域登录请求。两个是在不同根域下面的。
8.1 DNS
127.0.0.1 testclient.com
127.0.0.1 testserver.com
在/etc/hosts中加入以上dns
8.2 nginx
server{
listen 443 ssl;
server_name testclient.com;
ssl_certificate /usr/local/etc/nginx/conf/server.crt;
ssl_certificate_key /usr/local/etc/nginx/conf/server.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
location / {
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_pass http://localhost:9596;
}
}
加入TestClientTLS的配置,注意,自己生成一个本地证书。
server{
listen 443 ssl;
server_name testserver.com;
ssl_certificate /usr/local/etc/nginx/conf/server.crt;
ssl_certificate_key /usr/local/etc/nginx/conf/server.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
location / {
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_pass http://localhost:9595;
}
}
加入TestServerTLS的配置,注意,自己生成一个本地证书。
8.3 客户端cors
var globalCsrfToken = "";
function checkBody(response){
if( response.code == 0 ){
if( response.csrfToken ){
= response.csrfToken;
globalCsrfToken
}return;
}const error = new Error(response.msg);
.code = response.code;
error.msg = response.msg;
errorthrow error;
}
export default async function request(url, options) {
const defaultOptions = {
credentials: "include",
;
}const newOptions = {
...defaultOptions,
...options,
mode:"cors",
headers:{
'X-CSRF-TOKEN':globalCsrfToken,
}query:{
_t:new Date().valueOf(),
...options.query,
};
}
= "https://testserver.com"+url;
url let response = await fetch(url, newOptions);
if( newOptions.autoCheck ){
checkStatus(response);
}
let data = await response.json();
if( newOptions.autoCheck ){
checkBody(data);
} }
客户端cors也是需要加入mode的选项,但是CSRF防御的方式使用Session防御,它会在接口中接收csrfToken字段,然后写入到本地的globalCsrfToken。再下次提交请求的时候,都会附带上这个Token到X-CSRF-TOKEN的header字段。
8.4 服务器cors
package spring_test;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.util.Date;
/**
* Created by fish on 2021/6/2.
*/
public class MyCookieProcessor extends Rfc6265CookieProcessor {
public String generateHeader(javax.servlet.http.Cookie cookie, HttpServletRequest request) {
.setSecure(true);
cookiereturn super.generateHeader(cookie,request);
}
}
先定义一个CookieProcessor,将所有Cookie都打开Secure开关.
package spring_test;
import org.apache.tomcat.util.http.LegacyCookieProcessor;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Created by fish on 2021/6/1.
*/
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
@Bean
public TomcatContextCustomizer sameSiteCookiesConfig() {
return context -> {
final MyCookieProcessor cookieProcessor = new MyCookieProcessor();
.setSameSiteCookies(SameSiteCookies.NONE.getValue());
cookieProcessor.setCookieProcessor(cookieProcessor);
context};
}
}
在MvcConfiguration中打开SameSite为None的开关。
@Override
protected void configure(HttpSecurity http) throws Exception {
xxxxx= new HttpSessionCsrfTokenRepository();
HttpSessionCsrfTokenRepository csrfTokenRepository
.
http//开启csrf
csrf()
.csrfTokenRepository(csrfTokenRepository)
.and()
.xxxx
.authorizeRequests()
http.antMatchers("/login/login").permitAll()
.antMatchers("/login/islogin").permitAll()
.anyRequest().authenticated()
.and()
.cors();
}
@Bean
corsConfigurationSource(){
CorsConfigurationSource = new CorsConfiguration();
CorsConfiguration configuration .setAllowedOrigins(Arrays.asList("https://testclient.com"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration
= new UrlBasedCorsConfigurationSource();
UrlBasedCorsConfigurationSource source .registerCorsConfiguration("/**",configuration);
sourcereturn source;
}
WebSecurityConfigurerAdapter的配置倒是没什么变化,注意CSRF要使用HttpSessionCsrfTokenRepository的方式。
@Data
@AllArgsConstructor
public static class ResponseResult{
@JsonIgnore
private HttpStatus statusCode;
private int code;
private String msg;
private Object data;
private String csrfToken;
private void init(HttpStatus httpStatus,int code,String message,Object data){
this.code = code;
this.msg = message;
this.data = data;
this.statusCode = httpStatus;
= RequestContextHolder.currentRequestAttributes();
RequestAttributes requestAttributes = ((ServletRequestAttributes)requestAttributes).getRequest();
HttpServletRequest request
= (CsrfToken)request.getAttribute("org.springframework.security.web.csrf.CsrfToken");
CsrfToken token if( token != null ){
this.csrfToken = token.getToken();
}
}
public ResponseResult(int code,String message,Object data){
init(HttpStatus.OK,code,message,data);
}
public ResponseResult(HttpStatus httpStatus,int code,String message,Object data){
init(httpStatus,code,message,data);
}
}
最后设置一下ResponseResult,在返回的回复体都附带一个CsrfToken。
先登录https://testserver.com,强行打开这个没有根证书的域名。
然后打开https://testclient.com就可以登录了
9 OAuth
暂时没用到,就没看了。
11 切换用户
一个常见的场景是,管理员可以凭借自身权限,任意切换到其他的用户上。
@Bean
public SwitchUserFilter switchUserFilter(){
= new SwitchUserFilter();
SwitchUserFilter filter .setUserDetailsService(myUserDetailService);
filter.setSwitchUserUrl("/login/impersonate");
filter.setSuccessHandler(authSuccessHandler);
filter.setFailureHandler(authFailureHandler);
filterreturn filter;
}
加入切换用户的bean
@Override
protected void configure(HttpSecurity http) throws Exception {
...
.addFilterAfter(switchUserFilter(), FilterSecurityInterceptor.class);
http}
将SwitchUserFilter加入到Filter链里面
.authorizeRequests()
.antMatchers("/login/impersonate*").hasRole("ADMIN")
指定url有对应的权限即可
const onLoginChange = async ()=>{
await axios({
method:'POST',
url:'/login/impersonate',
params:{
username:data.name,
};
})window.location.reload();
}
切换的方法也比较简单,直接POST一个url,将name设置进去就可以了。
12 多租户
代码在这里
Spring Security要实现多租户需要支持:
- 在表单登录和Remember-Me登录以后,需要将当前的租户ID写入Cookie和UserDetail,写入Cookie的目的是为了在所有API上自动带上租户ID,方便网关路由。写入UserDetail的目的是,Session的租户ID更加安全可靠,是与Cookie的租户ID相比较以确定无篡改。
- 拉取和校验用户信息,以及Remember-me的持久化令牌,都需要设置租户ID,然后才去拉数据库
- 在Session并发控制的时候,需要根据userName和tenantId的组合来确定当前有多少个session。而不是仅仅通过userName来判断。
- Http的拦截器上,校验UserDetail上的tenantId,和cookie上的相比较,来确定是否合法
12.1 登录后写入Cookie
http .formLogin()
.successHandler(authSuccessHandler)
.rememberMe()
.authenticationSuccessHandler(new AuthenticationSuccessHandler(){
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication var3) throws IOException, ServletException{
if( myAuthSuccessHandler != null ){
.handle(false);
myAuthSuccessHandler}
}
})
设置successHandler的回调
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import spring_test.framework.MyAuthSuccessHandler;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
@Component
@Slf4j
public class MyTenantAuthSuccessHandler implements MyAuthSuccessHandler {
@Override
public void handle(boolean isFormLogin){
= ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
HttpServletResponse response String tenantId = MyTenantHolder.getTenantIdByRequest();
//写入cookie
try{
String tenantIdEncode = java.net.URLEncoder.encode(tenantId, "UTF-8");
= new Cookie("tenantId", tenantIdEncode);
Cookie nameCookie //设置Cookie的有效时间,单位为秒
.setMaxAge(7*3600*24);
nameCookie.setPath("/");
nameCookie.setHttpOnly(true);
nameCookie//通过response的addCookie()方法将此Cookie对象保存到客户端浏览器的Cookie中
.addCookie(nameCookie);
response}catch(UnsupportedEncodingException e){
throw new RuntimeException(e);
}
}
}
在校验成功以后,写入cookie和session
12.2 登录后写入UserDetail
package spring_test;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import spring_test.business.User;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class MyTenantUserDetails implements UserDetails {
private static final long serialVersionUID = 4359709211352400087L;
private String tenantId;
private String name;
private String password;
private Long userId;
private String role;
public MyTenantUserDetails(String tenantId, User user){
this.tenantId = tenantId;
this.name = user.getName();
this.password = user.getPassword();
this.userId = user.getId();
this.role = user.getRole().toString();
}
public Collection<? extends GrantedAuthority> getAuthorities(){
List<GrantedAuthority> authorities = new ArrayList<>();
.add(new SimpleGrantedAuthority(this.role));
authoritiesreturn authorities;
}
public String getTenantId(){
return this.tenantId;
}
public Long getUserId(){
return this.userId;
}
public String getPassword(){
return this.password;
}
public String getUsername(){
return this.name;
}
public boolean isAccountNonExpired(){
return true;
}
public boolean isAccountNonLocked(){
return true;
}
public boolean isCredentialsNonExpired(){
return true;
}
public boolean isEnabled(){
return true;
}
@Override
public boolean equals(Object obj){
if( obj instanceof MyTenantUserDetails){
= (MyTenantUserDetails) obj;
MyTenantUserDetails right return this.getUsername().equals(right.getUsername()) &&
this.getTenantId().equals(right.getTenantId());
}else{
return false;
}
}
@Override
public int hashCode(){
String link = this.getTenantId()+"#"+this.getUsername();
return link.hashCode();
}
}
自定义一个UserDetails,注意重写了hashCode和equals,要以name和tenantId的组合为匹配。
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import spring_test.business.User;
import spring_test.framework.MyUserDetail;
import spring_test.infrastructure.UserRepository;
@Component
@Slf4j
public class MyTenantUserDetailService implements UserDetailsService {
private UserRepository userRepository;
public MyTenantUserDetailService(UserRepository userRepository){
this.userRepository = userRepository;
}
public MyTenantUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String tenantId = MyTenantHolder.getTenantIdByRequest();
if( tenantId == null ){
throw new UsernameNotFoundException("缺少租户参数");
}
.setTenantId(tenantId);
MyTenantHolder
= userRepository.getByNameForRead(username);
User user if( user == null){
//这个异常是固定的,不能改其他的
throw new UsernameNotFoundException("用户不存在");
}
return new MyTenantUserDetails(tenantId,user);
}
}
实现MyTenantUserDetailService,设置租户ID以后才读取数据库
package spring_test;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;
import javax.sql.DataSource;
import java.util.Date;
public class MyTenantPersistentTokenRepository extends JdbcTokenRepositoryImpl {
public MyTenantPersistentTokenRepository(DataSource dataSource){
this.setDataSource(dataSource);
}
@Override
public void createNewToken(PersistentRememberMeToken token){
String tenantId = MyTenantHolder.getTenantIdByRequest();
if( tenantId == null ){
throw new RuntimeException("缺少租户ID");
}
.setTenantId(tenantId);
MyTenantHoldersuper.createNewToken(token);
}
@Override
public void updateToken(String key, String value, Date date){
String tenantId = MyTenantHolder.getTenantIdByRequest();
if( tenantId == null ){
throw new RuntimeException("缺少租户ID");
}
.setTenantId(tenantId);
MyTenantHoldersuper.updateToken(key,value,date);
}
@Override
public PersistentRememberMeToken getTokenForSeries(String key){
String tenantId = MyTenantHolder.getTenantIdByRequest();
if( tenantId == null ){
return null;
}
.setTenantId(tenantId);
MyTenantHolderreturn super.getTokenForSeries(key);
}
@Override
public void removeUserTokens(String key){
String tenantId = MyTenantHolder.getTenantIdByRequest();
if( tenantId == null ){
throw new RuntimeException("缺少租户ID");
}
.setTenantId(tenantId);
MyTenantHoldersuper.removeUserTokens(key);
}
}
Remember-me的持久化令牌中,每个接口都需要先设置租户ID,注意,getTokenForSeries没有租户ID的时候,直接返回null就可以了。
12.3 业务前的租户ID设置和校验
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import spring_test.framework.MyException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
@Slf4j
public class MyTenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenantId = MyTenantHolder.getTenantIdByRequest();
.setTenantId(tenantId);
MyTenantHolder//检查cookie与session是否一致
= (SecurityContextImpl)request.getSession().getAttribute("SPRING_SECURITY_CONTEXT");
SecurityContextImpl securityContextImpl if( securityContextImpl != null ){
//FIXME,前端需要对以下错误进行处理,收到以下错误以后自动跳转到登录页面
= (MyTenantUserDetails)securityContextImpl.getAuthentication().getPrincipal();
MyTenantUserDetails userDetail
.info("tenantId {}, loginInfo {}",tenantId,userDetail);
logif( userDetail.getTenantId() == null ){
throw new MyException(1,"缺少租户ID",null);
}
if( tenantId.equals(userDetail.getTenantId()) == false ){
throw new MyException(1,"租户ID不一致",null);
}
}
return true;
}
}
当登录态为空的时候,无需校验租户ID的信息
13 Session
代码在这里
13.1 Session过期时间
# session的默认保留时间, SpringBoot 1.x的配置
spring.session.timeout = 1h
# session的默认保留时间, SpringBoot 2.x的配置
server.servlet.session.timeout = 1h
注意在不同版本的配置
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@Slf4j
public class MainConfig {
//两个bean,其中二选一
@Bean
public TomcatContextCustomizer sameSiteCookiesConfig() {
return context -> {
= context;
TOMCAT_CONTEXT .info("session timeout {} minutes", context.getSessionTimeout());
log};
}
public static org.apache.catalina.Context TOMCAT_CONTEXT;
}
启动以后,我们可以通过TomcatContextCustomizer来查看实际的配置
13.2 查看当前所有的Session
package spring_test;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.Session;
import org.apache.catalina.core.StandardContext;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.RememberMeAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import spring_test.business.User;
import spring_test.framework.MyException;
import spring_test.framework.MyUserDetail;
import spring_test.infrastructure.UserRepository;
import javax.persistence.Access;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.*;
import java.util.stream.Collectors;
/**
* Created by fish on 2021/4/26.
*/
@RestController
@RequestMapping("/session")
@Slf4j
public class SessionController {
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public static class SessionInfo {
private String id;
private Date createTime;
private Date lastActiveTime;
private Integer maxIdleSecond;
private Long currentIdleMilliSecond;
private Map<String,String> attributes;
}
@GetMapping("/getAll")
public List<SessionInfo> getAll(HttpServletRequest request){
[] sessions = MainConfig.TOMCAT_CONTEXT.getManager().findSessions();
Sessionreturn Arrays.stream(sessions).map(single->{
Map<String,String> attributes = new HashMap<>();
= single.getSession();
HttpSession s Enumeration<String> names = s.getAttributeNames();
while( names.hasMoreElements() ){
String name = names.nextElement();
String value = s.getAttribute(name).toString();
.put(name,value);
attributes}
return new SessionInfo()
.setId(single.getId())
.setCreateTime(new Date(single.getCreationTime()))
.setLastActiveTime(new Date(single.getLastAccessedTime()))
.setMaxIdleSecond(single.getMaxInactiveInterval())
.setCurrentIdleMilliSecond(single.getIdleTimeInternal())
.setAttributes(attributes);
}).collect(Collectors.toList());
}
}
记录TomcatContextCustomizer的Context,然后getManager就能获取到所有的Session了,可以清晰看到登录用户或者未登录用户的session信息。
19 FAQ
19.1 Remember-me意外丢失
public class PersistentTokenBasedRememberMeServices{
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
= this.tokenRepository.getTokenForSeries(presentedSeries);
PersistentRememberMeToken token if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'", token.getUsername(), token.getSeries()));
= new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
PersistentRememberMeToken newToken
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
}
}
在processAutoLoginCookie的时候,如果发现presentedSeries对应的tokenValue不匹配,就会进行删除removeUserTokens的操作。这种设计的出发点是:
- A用户登入,获取remember-me,然后他退出了。
- B用户窃取了A用户的remember-me的token,登入了A的系统
- A用户二次登录的时候,remember-me的token已经发生了变更
Spring Securitys发现同一个token被更新了多次,意味着token被发现,所以删除了该用户的所有token。
但是,这种设计,并没有考虑一个并发的现实问题。A用户登入的时候,携带的是一个过期的sessionId,以及有效的remember-me token。而且,它同时执行了两个请求。
- A请求使用的(过期sessionId + 有效的remember-me token),这个时候,Spring Security刷新了后台的remember-me token,以及返回了一个新的sessionId
- B请求使用的(过期sessionId + 有效的remember-me token),这个时候,Spring Security发现remember-me token已经刷新过了,就会触发CookieTheftException异常。
两个并发请求执行remember-me token刷新,就会触发CookieTheftException异常,导致所有的remember-me的Token都全部被删除了。
解决方法有两种:
- 尽量避免过期sessionId去执行后端,例如可以是系统初始化的时候,每次都用remember-me的Token去获取新的sessionId,而不复用上次使用sessionId,这样的sessionId的使用时间是最长的。或者,仅允许用单线程周期性地使用remember-me的Token来获取新的sessionId,刷新的过程中,其他请求需要暂停发送。(客户端要求较高)
- remember-me的token在删除的时候,使用value为random的方式来实现软删除。并发出现问题的时候,仅有B请求是失败的,并不会造成其他请求也崩掉的情况。(会丢掉窃取remember-me的token安全性)
20 总结
Spring Security总体而言还是比较复杂,这种复杂来源于:
- 本身的Web安全理论就是复杂
- 设计自身为了支持Http认证,表单认证,OAuth认证,以及为了扩展性支持不同的csrfTokenRepository,UserDetailService,sessionRegistry等等巨大扩展性引用的复杂性。
总体而言,使用复杂,架构设计也确实漂亮,能同时支持这么多的特性。
- 本文作者: fishedee
- 版权声明: 本博客所有文章均采用 CC BY-NC-SA 3.0 CN 许可协议,转载必须注明出处!