烦恼的紫菜汤 · 前端常见的安全问题及防范措施 - 前端南玖 ...· 昨天 · |
彷徨的松球 · 跨域问题详解 - D13 - 博客园· 6 天前 · |
傻傻的甘蔗 · ASP.NET ...· 1 周前 · |
酷酷的便当 · 深入了解 cURL:你必须知道的命令行数据传输工具· 1 周前 · |
追风的烤土司 · 从Control Hub访问专用实例应用程序· 2 周前 · |
想出国的钱包 · VBA-ExcelHome· 2 月前 · |
很拉风的山羊 · Outlook Mail fetching ...· 3 月前 · |
不羁的领带 · Sambert语音合成API详情_模型服务灵 ...· 4 月前 · |
暴走的骆驼 · Hello, NilEra :-)· 4 月前 · |
越狱的人字拖 · 初级原画插画该学习的项目视频!!!!【中文字 ...· 5 月前 · |
域名服务器 网站服务器 cors cookie |
https://blog.fishedee.com/2021/05/31/%E3%80%8ASpringSecurity%E5%AE%9E%E6%88%98%E3%80%8B%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/ |
傻傻的键盘
1 月前 |
授权与验证有一大堆的相关的安全知识
HTTP是无状态协议,无法有效地追踪用户的状态,无法有效地根据不同用户展示不同的页面。后来HTTP发展了Cookie和Session来解决这个问题。
这是一个普通的表单登录
登录成功后,服务器返回一个Set-Cookie字段,关键在于JSESSIONID的这个Cookie
登录成功以后,之后的所有页面请求都会带有这个JSESSIONID的Cookie,服务器就能根据这个Cookie知道是哪个用户在访问当前页面了。注意,在上一次返回了JSESSIONID以后,下一次的页面就不再返回JSESSIONID了。
Set-Cookie: JSESSIONID=2C8346A1EBD87CB442F89DCD1E334BFA; Domain=test.com; Path=/; HttpOnly
注意,这个JSESSIONID的写法,是没有过期时间的。这种没有过期时间的Cookie称为Session,浏览器会在它退出的时候,自动删除这个Cookie。所以,我们下次登录的时候,服务器依然不知道我们是谁,就会要求我们再次输入账号与密码。
我们平时登录的时候,都会看到一个“记住我”的按钮。点击以后,下次退出浏览器以后再登录,服务器也会知道我们是谁,我们不需要再次输入账号与密码。
Set-Cookie: JSESSIONID=2C8346A1EBD87CB442F89DCD1E334BFA; Domain=test.com; Max-Age=3600; Expires=Wed, 02-Jun-2021 11:48:58 GMT; Path=/; HttpOnly
一个直观的做法,是直接给JSESSIONID赋予过期时间,这样退出浏览器以后再次登录,浏览器依然会发送这个JSESSIONID的值。
但是,这样做会有个问题。服务器无法区分,当前用户是输入账号密码授权进来的,还是退出浏览器以后重新进来的。两者的安全性明显不同,我们需要对不同授权方式进来的用户给与不同的权限。
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进来的,而不是通过输入账号密码进来的。
其实JSESSIONID与remember-me的关系就像,AccessToken与RefreshToken的关系。AccessToken代表你的凭证,RefreshToken是可以后备保存来刷新凭证。AccessToken的有效期比较短,所以即使被盗也是影响有限,RefreshToken是存放在本地的Token,很少在网络上传输的,安全性会更强。
但是由于浏览器的关系,remember-me的Cookie是每次都会在网络上传输,它的安全性相比RefreshToken就差很多了。
会话固定攻击,看 这里
会话固定攻击常用于在Url中传递JSESSIONID。
因此,我们的防御方式是:
CSRF攻击,看 这里
他的攻击在于,在A网站,可以发送到B网站的跨域请求,但是这个请求只能发送,不能获取结果的。在普通的浏览器上,跨域请求都会附带跨域的Cookie,例如A网站请求B网站的请求时,该请求附带B网站的Cookie,并且会被B网站的服务器处理。浏览器仅仅是不允许A网站读取该跨域请求的返回结果而已。
请求会到达B网站服务器
但是,A网站在请求完毕后,无法读取请求的返回结果,浏览器会报出statusCode为0的错误。
但是,尽管如此,该跨域请求确实附带了B网站的Cookie,也被B网站的服务器处理了,这样最终导致CSRF攻击。你想想你在一个A网站浏览的时候,发现自己登录过的其他银行网站被突然转账了是一件多可怕的事情。
一个简单的防御是,服务器B检查,请求的来源,Origin与Referer是不是自己的网站,不是的话就直接拒绝。但是这个方法比较少用,因为Referer在不同浏览器上的实现不同,而且,Referer涉及到用户隐私,用户可以在浏览器中设置请求中不提供Referer属性。
CSRF攻击与普通请求的关键在于,CSRF攻击总是跨域的。那么跨域请求与普通请求的最大区别在于:
因此,我们根据这两个不同的构造出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; RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes(); HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest(); Enumeration<String> days = request.getAttributeNames(); while( days.hasMoreElements()){ log.info("attributes {}",days.nextElement()); CsrfToken token = (CsrfToken)request.getAttribute("org.springframework.security.web.csrf.CsrfToken"); 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 ""; newOptions.headers = { '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
前端用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<>(); authorities.add(new SimpleGrantedAuthority(this.role)); return 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{ User user = userRepository.getByNameForRead(username); 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(){ PasswordEncoder encoder = new BCryptPasswordEncoder(12); return encoder; @Autowired private MyUserDetailService myUserDetailService; @Bean public UserDetailsService userDetailsService(){ return myUserDetailService; @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailService) .passwordEncoder(passwordEncoder());定义密码编码器,并且我们将UserDetailService与PasswordEncoder通过configure配置进去
2.3 登录与登出
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); 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); http.authorizeRequests() .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 ObjectMapper mapper = new ObjectMapper(); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException{ response.setStatus(HttpServletResponse.SC_OK); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); String result = mapper.writeValueAsString(new MyResponseBodyAdvice.ResponseResult(1,"未登录",null)); PrintWriter writer = response.getWriter(); writer.write(result); writer.flush();
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{ response.setStatus(403); response.getWriter().write("Forbidden:" + accessDeniedException.getMessage());
AccessDeniedHandler是用户已经授权,但是角色权限不足的情况时,被SpringSecurity阻止的逻辑。
3 记住我
3.1 配置
@Override protected void configure(HttpSecurity http) throws Exception { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); http. csrf() .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 会话并发
4.2.1 实现
@Override protected void configure(HttpSecurity http) throws Exception { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); 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); http.authorizeRequests() .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); sessionsUsedByPrincipal.add(sessionId);
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<>(); authorities.add(new SimpleGrantedAuthority(this.role)); return 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 ObjectMapper mapper = new ObjectMapper(); @Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException{ HttpServletResponse response = event.getResponse(); HttpServletRequest request = event.getRequest(); //清空remember-me的cookie Cookie[] cookies = request.getCookies(); for (Cookie cookie : cookies) { if (cookie.getName().contains("remember-me")) { String cookieName = cookie.getName(); Cookie newCookie = new Cookie(cookieName, null); newCookie.setPath("/"); response.addCookie(newCookie); break; //返回登录过期了 response.setStatus(HttpServletResponse.SC_OK); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); String result = mapper.writeValueAsString(new MyResponseBodyAdvice.ResponseResult(20001,"你的账号在其他地方登录了",null)); PrintWriter writer = response.getWriter(); writer.write(result); writer.flush();
最后,我们可以重写当踢掉旧会话上线时的处理逻辑,MySessionInformationExpiredStrategy。这里要注意删除remember-me的Cookie,这样才会让用户避免再次上线。如果不删除remember-me的Cookie,用户只要再刷新一次页面就能进入了。
4.2.2 原理
主要流程:
UsernamePasswordAuthenticationFilter,在doFilter里面attemptAuthentication。 然后执行AbstractAuthenticationProcessingFilter的this.sessionStrategy.onAuthentication 最后SessionAuthenticationStrategy,包含了ConcurrentSessionControlAuthenticationStrategy,ConcurrentSessionControlAuthenticationStrategy里面会取出当前getPrincipal的所有session,对它们做踢下线的处理 public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String username = this.obtainUsername(request); username = username != null ? username.trim() : ""; String password = this.obtainPassword(request); password = password != null ? password : ""; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest);
UsernamePasswordAuthenticationFilter就是账号密码的登录方式,继承自AbstractAuthenticationProcessingFilter
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware { private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy(); public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain); private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (!this.requiresAuthentication(request, response)) { chain.doFilter(request, response); } else { try { Authentication authenticationResult = this.attemptAuthentication(request, response); if (authenticationResult == null) { return; this.sessionStrategy.onAuthentication(authenticationResult, request, response); if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); this.successfulAuthentication(request, response, chain, authenticationResult); } catch (InternalAuthenticationServiceException var5) { this.logger.error("An internal error occurred while trying to authenticate the user.", var5); this.unsuccessfulAuthentication(request, response, var5); } catch (AuthenticationException var6) { this.unsuccessfulAuthentication(request, response, var6);UsernamePasswordAuthenticationFilter首先会执行doFilter,doFilter先执行attemptAuthentication,然后执行this.sessionStrategy.onAuthentication。在实际运行中,sessionStrategy是CompositeSessionAuthenticationStrategy。它包含了多个sessionStrategy,其中一个就是ConcurrentSessionControlAuthenticationStrategy
public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy { protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private final SessionRegistry sessionRegistry; private boolean exceptionIfMaximumExceeded = false; private int maximumSessions = 1; public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) { Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null"); this.sessionRegistry = sessionRegistry; public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) { int allowedSessions = this.getMaximumSessionsForThisUser(authentication); if (allowedSessions != -1) { List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false); int sessionCount = sessions.size(); if (sessionCount >= allowedSessions) { if (sessionCount == allowedSessions) { HttpSession session = request.getSession(false); if (session != null) { Iterator var8 = sessions.iterator(); while(var8.hasNext()) { SessionInformation si = (SessionInformation)var8.next(); if (si.getSessionId().equals(session.getId())) { return; this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry); protected int getMaximumSessionsForThisUser(Authentication authentication) { return this.maximumSessions; protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException { if (!this.exceptionIfMaximumExceeded && sessions != null) { sessions.sort(Comparator.comparing(SessionInformation::getLastRequest)); int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1; List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy); Iterator var6 = sessionsToBeExpired.iterator(); while(var6.hasNext()) { SessionInformation session = (SessionInformation)var6.next(); session.expireNow(); } else { throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded")); public void setExceptionIfMaximumExceeded(boolean exceptionIfMaximumExceeded) { this.exceptionIfMaximumExceeded = exceptionIfMaximumExceeded; public void setMaximumSessions(int maximumSessions) { Assert.isTrue(maximumSessions != 0, "MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum"); this.maximumSessions = maximumSessions; public void setMessageSource(MessageSource messageSource) { Assert.notNull(messageSource, "messageSource cannot be null"); this.messages = new MessageSourceAccessor(messageSource);
ConcurrentSessionControlAuthenticationStrategy的代码比较易懂,关键是取this.sessionRegistry.getAllSessions,然后执行expireNow
4.3 会话存储
默认情况下,会话存放在内存中,当应用重新启动的时候,会话都会丢失。
但是,在IDEA下,直接restart应用,会话会自动保存重放,这样能避免开发环境下需要不断登录。同时,有时候IDEA这样做会给你带来困惑,让你怀疑会话不是存放在内存中的。具体看这里和这里
4.4 集群会话
http.sessionManagement() .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 { SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 5); specCaptcha.setCharType(Captcha.TYPE_NUM_AND_UPPER); String verCode = specCaptcha.text().toLowerCase(); //将结果写入到session中 request.getSession().setAttribute("captcha",verCode); return 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())){ filterChain.doFilter(httpServletRequest,httpServletResponse); }else{ try { vertifyCode(httpServletRequest, httpServletResponse); filterChain.doFilter(httpServletRequest, httpServletResponse); }catch(AuthenticationException e ){ authFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,e); public void vertifyCode(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)throws AuthenticationException{ String requestCode = httpServletRequest.getParameter("captcha"); HttpSession session = httpServletRequest.getSession(); String savedCode = (String)session.getAttribute("captcha"); //清除验证码,只有一次尝试机会 session.removeAttribute("captcha"); 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 { SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 5); String verCode = specCaptcha.text().toLowerCase(); String key = UUID.randomUUID().toString(); // 存入redis并设置过期时间为30分钟 redisUtil.setEx(key, verCode, 30, TimeUnit.MINUTES); // 将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 { JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); http. //开启csrf csrf() //默认的headerName为"X-XSRF-TOKEN"; //默认的cookieName为"XSRF-TOKEN"; //默认的parameterName的"_csrf"; //可以看一下CookieCsrfTokenRepository的源代码 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .and()
打开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, url = "http://server.test.com"+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) { cookie.setDomain("test.com"); return 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(); context.setCookieProcessor(cookieProcessor);
将MyCookieProcessor注入到MvcConfiguration中。
@Override protected void configure(HttpSecurity http) throws Exception { xxxxx http.authorizeRequests() .antMatchers("/login/login").permitAll() .antMatchers("/login/islogin").permitAll() .anyRequest().authenticated() .and() .cors(); @Bean CorsConfigurationSource corsConfigurationSource(){ CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("https://client.test.com","http://client.test.com")); configuration.setAllowedMethods(Arrays.asList("*")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**",configuration); return 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 ){ globalCsrfToken = response.csrfToken; return; const error = new Error(response.msg); error.code = response.code; error.msg = response.msg; throw 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, url = "https://testserver.com"+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) { cookie.setSecure(true); return 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(); cookieProcessor.setSameSiteCookies(SameSiteCookies.NONE.getValue()); context.setCookieProcessor(cookieProcessor);
在MvcConfiguration中打开SameSite为None的开关。
@Override protected void configure(HttpSecurity http) throws Exception { xxxxx HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository(); http. //开启csrf csrf() .csrfTokenRepository(csrfTokenRepository) .and() .xxxx http.authorizeRequests() .antMatchers("/login/login").permitAll() .antMatchers("/login/islogin").permitAll() .anyRequest().authenticated() .and() .cors(); @Bean CorsConfigurationSource corsConfigurationSource(){ CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("https://testclient.com")); configuration.setAllowedMethods(Arrays.asList("*")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**",configuration); return 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; RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes(); HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest(); CsrfToken token = (CsrfToken)request.getAttribute("org.springframework.security.web.csrf.CsrfToken"); 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(){ SwitchUserFilter filter = new SwitchUserFilter(); filter.setUserDetailsService(myUserDetailService); filter.setSwitchUserUrl("/login/impersonate"); filter.setSuccessHandler(authSuccessHandler); filter.setFailureHandler(authFailureHandler); return filter;
加入切换用户的bean
@Override protected void configure(HttpSecurity http) throws Exception { http.addFilterAfter(switchUserFilter(), FilterSecurityInterceptor.class);
将SwitchUserFilter加入到Filter链里面
指定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
.formLogin() .successHandler(authSuccessHandler) .rememberMe() .authenticationSuccessHandler(new AuthenticationSuccessHandler(){ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication var3) throws IOException, ServletException{ if( myAuthSuccessHandler != null ){ myAuthSuccessHandler.handle(false);设置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){ HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse(); String tenantId = MyTenantHolder.getTenantIdByRequest(); //写入cookie try{ String tenantIdEncode = java.net.URLEncoder.encode(tenantId, "UTF-8"); Cookie nameCookie = new Cookie("tenantId", tenantIdEncode); //设置Cookie的有效时间,单位为秒 nameCookie.setMaxAge(7*3600*24); nameCookie.setPath("/"); nameCookie.setHttpOnly(true); //通过response的addCookie()方法将此Cookie对象保存到客户端浏览器的Cookie中 response.addCookie(nameCookie); }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<>(); authorities.add(new SimpleGrantedAuthority(this.role)); return 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 right = (MyTenantUserDetails) obj; 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("缺少租户参数"); MyTenantHolder.setTenantId(tenantId); User user = userRepository.getByNameForRead(username); 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"); MyTenantHolder.setTenantId(tenantId); super.createNewToken(token); @Override public void updateToken(String key, String value, Date date){ String tenantId = MyTenantHolder.getTenantIdByRequest(); if( tenantId == null ){ throw new RuntimeException("缺少租户ID"); MyTenantHolder.setTenantId(tenantId); super.updateToken(key,value,date); @Override public PersistentRememberMeToken getTokenForSeries(String key){ String tenantId = MyTenantHolder.getTenantIdByRequest(); if( tenantId == null ){ return null; MyTenantHolder.setTenantId(tenantId); return super.getTokenForSeries(key); @Override public void removeUserTokens(String key){ String tenantId = MyTenantHolder.getTenantIdByRequest(); if( tenantId == null ){ throw new RuntimeException("缺少租户ID"); MyTenantHolder.setTenantId(tenantId); super.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(); MyTenantHolder.setTenantId(tenantId); //检查cookie与session是否一致 SecurityContextImpl securityContextImpl = (SecurityContextImpl)request.getSession().getAttribute("SPRING_SECURITY_CONTEXT"); if( securityContextImpl != null ){ //FIXME,前端需要对以下错误进行处理,收到以下错误以后自动跳转到登录页面 MyTenantUserDetails userDetail = (MyTenantUserDetails)securityContextImpl.getAuthentication().getPrincipal(); log.info("tenantId {}, loginInfo {}",tenantId,userDetail); if( 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 -> { TOMCAT_CONTEXT = context; log.info("session timeout {} minutes", context.getSessionTimeout()); 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){ Session[] sessions = MainConfig.TOMCAT_CONTEXT.getManager().findSessions(); return Arrays.stream(sessions).map(single->{ Map<String,String> attributes = new HashMap<>(); HttpSession s = single.getSession(); Enumeration<String> names = s.getAttributeNames(); while( names.hasMoreElements() ){ String name = names.nextElement(); String value = s.getAttribute(name).toString(); attributes.put(name,value); 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信息。
18 原理
18.1 Filter链
Spring Security的所有操作都是用Filter链串起来的。其他的sessionStrategy,authenticationProvider的都是filter中的各个子模块,子变量而已
登录流程,UsernamePasswordAuthenticationFilter18.2.1 主要流程
UsernamePasswordAuthenticationFilter,执行AbstractAuthenticationProcessingFilter的doFilter AbstractAuthenticationProcessingFilter的doFilter, 先执行UsernamePasswordAuthenticationFilter的attemptAuthentication,再执行sessionStrategy.onAuthentication,最后进行successfulAuthentication通知。 UsernamePasswordAuthenticationFilter的attemptAuthentication里面,先获取参数,然后创建UsernamePasswordAuthenticationToken(isAuth:false) UsernamePasswordAuthenticationToken(isAuth:false),交给AuthenticationManager。AuthenticationManager里面有很多AuthenticationProvider,它寻找其中一个DaoAuthenticationProvider来做UsernamePasswordAuthenticationToken的校验,最终转换为UsernamePasswordAuthenticationToken(isAuth:true) DaoAuthenticationProvider里面会调用UserDetailService,passwordEncoder来做校验账号密码。 理解AbstractAuthenticationProcessingFilter 理解AuthenticationToken 理解AuthenticationProvider 理解AuthenticationDetailsSource 18.2.2 AbstractAuthenticationProcessingFilter
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String username = this.obtainUsername(request); username = username != null ? username.trim() : ""; String password = this.obtainPassword(request); password = password != null ? password : ""; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest);
UsernamePasswordAuthenticationFilter就是账号密码的登录方式,继承自AbstractAuthenticationProcessingFilter
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware { private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy(); public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain); private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (!this.requiresAuthentication(request, response)) { chain.doFilter(request, response); } else { try { Authentication authenticationResult = this.attemptAuthentication(request, response); if (authenticationResult == null) { return; this.sessionStrategy.onAuthentication(authenticationResult, request, response); if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); this.successfulAuthentication(request, response, chain, authenticationResult); } catch (InternalAuthenticationServiceException var5) { this.logger.error("An internal error occurred while trying to authenticate the user.", var5); this.unsuccessfulAuthentication(request, response, var5); } catch (AuthenticationException var6) { this.unsuccessfulAuthentication(request, response, var6); protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authResult); SecurityContextHolder.setContext(context); this.securityContextRepository.saveContext(context, request, response); if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult)); this.rememberMeServices.loginSuccess(request, response, authResult); if (this.eventPublisher != null) { this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass())); this.successHandler.onAuthenticationSuccess(request, response, authResult);UsernamePasswordAuthenticationFilter首先会执行doFilter,doFilter工作:
先执行attemptAuthentication,也就是UsernamePasswordAuthenticationFilter里面attemptAuthentication的内容 然后执行this.sessionStrategy.onAuthentication,进行sessionStrategy的处理,这里面包含了ConcurrentSessionControlAuthenticationStrategy的处理 最后调用successfulAuthentication,包括了写入SecurityContext,以及rememberMeServices.loginSuccess(将rememberMe的Token写入数据库,并返回rememberMe的Token),和对于返回success event. 18.2.3 UsernamePasswordAuthenticationFilter
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String username = this.obtainUsername(request); username = username != null ? username.trim() : ""; String password = this.obtainPassword(request); password = password != null ? password : ""; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
UsernamePasswordAuthenticationFilter比较简单
获取username和password参数 创建一个未验证的UsernamePasswordAuthenticationToken 在UsernamePasswordAuthenticationToken里面,setDetails,就是登录的sessionId, ip和时间这些信息,由authenticationDetailsSource提供。 最后调用getAuthenticationManager来校验这个UsernamePasswordAuthenticationToken。它最终会找到DaoAuthenticationProvider来做UsernamePasswordAuthenticationToken校验。 public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; private final Object principal; private Object credentials; * This constructor can be safely used by any code that wishes to create a * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()} * will return <code>false</code>. public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super(null); this.principal = principal; this.credentials = credentials; setAuthenticated(false); * This constructor should only be used by <code>AuthenticationManager</code> or * <code>AuthenticationProvider</code> implementations that are satisfied with * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>) * authentication token. * @param principal * @param credentials * @param authorities public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.credentials = credentials; super.setAuthenticated(true); // must use super, as we override * This factory method can be safely used by any code that wishes to create a * unauthenticated <code>UsernamePasswordAuthenticationToken</code>. * @param principal * @param credentials * @return UsernamePasswordAuthenticationToken with false isAuthenticated() result * @since 5.7 public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) { return new UsernamePasswordAuthenticationToken(principal, credentials); * This factory method can be safely used by any code that wishes to create a * authenticated <code>UsernamePasswordAuthenticationToken</code>. * @param principal * @param credentials * @return UsernamePasswordAuthenticationToken with true isAuthenticated() result * @since 5.7 public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { return new UsernamePasswordAuthenticationToken(principal, credentials, authorities); @Override public Object getCredentials() { return this.credentials; @Override public Object getPrincipal() { return this.principal; @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); super.setAuthenticated(false); @Override public void eraseCredentials() { super.eraseCredentials(); this.credentials = null;
UsernamePasswordAuthenticationToken的实现比较简单,没啥好说的
18.2.4 DaoAuthenticationProvider
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { private UserDetailsService userDetailsService; private UserDetailsPasswordService userDetailsPasswordService; private PasswordEncoder passwordEncoder; @Override protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { prepareTimingAttackProtection(); try { UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); if (loadedUser == null) { throw new InternalAuthenticationServiceException( "UserDetailsService returned null, which is an interface contract violation"); return loadedUser; catch (UsernameNotFoundException ex) { mitigateAgainstTimingAttack(authentication); throw ex; catch (InternalAuthenticationServiceException ex) { throw ex; catch (Exception ex) { throw new InternalAuthenticationServiceException(ex.getMessage(), ex); @Override @SuppressWarnings("deprecation") protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if (authentication.getCredentials() == null) { this.logger.debug("Failed to authenticate since no credentials provided"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); String presentedPassword = authentication.getCredentials().toString(); if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
DaoAuthenticationProvider里面继承自AbstractUserDetailsAuthenticationProvider,里面只实现了retrieveUser和additionalAuthenticationChecks方法。
class abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); String username = determineUsername(authentication); boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); catch (UsernameNotFoundException ex) { this.logger.debug("Failed to find user '" + username + "'"); if (!this.hideUserNotFoundExceptions) { throw ex; throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); try { this.preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); catch (AuthenticationException ex) { if (!cacheWasUsed) { throw ex; // There was a problem, so try again after checking // we're using latest data (i.e. not from the cache) cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); this.preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); this.postAuthenticationChecks.check(user); if (!cacheWasUsed) { this.userCache.putUserInCache(user); Object principalToReturn = user; if (this.forcePrincipalAsString) { principalToReturn = user.getUsername(); return createSuccessAuthentication(principalToReturn, authentication, user); protected abstract UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException; protected abstract void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException; protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { // Ensure we return the original credentials the user supplied, // so subsequent attempts are successful even with encoded passwords. // Also ensure we return the original getDetails(), so that future // authentication events after cache expiry contain the details UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.authenticated(principal, authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities())); result.setDetails(authentication.getDetails()); this.logger.debug("Authenticated user"); return result; @Override public boolean supports(Class<?> authentication) { return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
AbstractUserDetailsAuthenticationProvider的工作:
使用supports函数,告诉AuthenticationManager,它是负责校验UsernamePasswordAuthenticationToken的。 在authenticate校验流程中,先获取用户名determineUsername,然后retrieveUser来获取用户,然后用additionalAuthenticationChecks来校验密码 校验成功的话,重新创建一个UsernamePasswordAuthenticationToken,然后给前端即可 创建流程,FormLoginConfigurer在前面流程中,可以看到,每个filter都有很多成员需要初始化,这些成员在哪里初始化呢。
主要流程:
在WebSecurityConfiguration的setFilterChainProxySecurityConfigurer创建WebSecurity 在WebSecurityConfiguration的springSecurityFilterChain,执行WebSecurity的build,继而执行它的doBuild 执行WebScurity的init, configure和performBuild,目标就是SecurityBoostConfiguration的三个流程。 SecurityBoostConfiguration的init,创建HttpSecurity,并执行void configure(HttpSecurity http),这里会将用户自定义的SecurityConfigurer添加进HttpSecurity。 SecurityBoostConfiguration的configure为空 SecurityBoostConfiguration的performBuild,执行HttpSecurity的doBuild流程,也就是遍历它所有SecurityConfigurer的init和configure。 理解WebSecurity,并调用WebSecurityConfigurerAdapter的三流程 WebSecurityConfigurerAdapter创建HttpSecurity HttpSecurity调用configure来配置 然后执行它自身的SecurityConfigurer的init和configure 18.3.1 WebSecurity
@Configuration( proxyBeanMethods = false public class WebSecurityConfiguration implements ImportAware, BeanClassLoaderAware { private WebSecurity webSecurity; private Boolean debugEnabled; @Bean( name = {"springSecurityFilterChain"} public Filter springSecurityFilterChain() throws Exception { while(true) { return (Filter)this.webSecurity.build(); @Autowired( required = false public void setFilterChainProxySecurityConfigurer(ObjectPostProcessor<Object> objectPostProcessor, ConfigurableListableBeanFactory beanFactory) throws Exception { this.webSecurity = (WebSecurity)objectPostProcessor.postProcess(new WebSecurity(objectPostProcessor));使用setFilterChainProxySecurityConfigurer来创建WebScurity 使用springSecurityFilterChain来执行WebScurity.build public final class WebSecurity extends AbstractConfiguredSecurityBuilder<Filter, WebSecurity> { protected Filter performBuild() throws Exception { var4 = this.securityFilterChainBuilders.iterator(); while(var4.hasNext()) { RequestMatcher ignoredRequest = (RequestMatcher)var4.next(); SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder = (SecurityBuilder)var4.next(); SecurityFilterChain securityFilterChain = (SecurityFilterChain)securityFilterChainBuilder.build(); this.postBuildAction.run(); return (Filter)result;WebSecurity继承自AbstractConfiguredSecurityBuilder,并实现了它的performBuild方法
public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>> extends AbstractSecurityBuilder<O> { public final O build() throws Exception { if (this.building.compareAndSet(false, true)) { this.object = this.doBuild(); return this.object; } else { throw new AlreadyBuiltException("This object has already been built"); protected final O doBuild() throws Exception { synchronized(this.configurers) { this.buildState = AbstractConfiguredSecurityBuilder.BuildState.INITIALIZING; this.beforeInit(); this.init(); this.buildState = AbstractConfiguredSecurityBuilder.BuildState.CONFIGURING; this.beforeConfigure(); this.configure(); this.buildState = AbstractConfiguredSecurityBuilder.BuildState.BUILDING; O result = this.performBuild(); this.buildState = AbstractConfiguredSecurityBuilder.BuildState.BUILT; return result; private void init() throws Exception { Collection<SecurityConfigurer<O, B>> configurers = this.getConfigurers(); Iterator var2 = configurers.iterator(); SecurityConfigurer configurer; while(var2.hasNext()) { configurer = (SecurityConfigurer)var2.next(); configurer.init(this); var2 = this.configurersAddedInInitializing.iterator(); while(var2.hasNext()) { configurer = (SecurityConfigurer)var2.next(); configurer.init(this); private void configure() throws Exception { Collection<SecurityConfigurer<O, B>> configurers = this.getConfigurers(); Iterator var2 = configurers.iterator(); while(var2.hasNext()) { SecurityConfigurer<O, B> configurer = (SecurityConfigurer)var2.next(); configurer.configure(this); protected abstract O performBuild() throws Exception;
build里面执行doBuild doBuild有三个关键流程,init, configure, 和performBuild. 在实际运行中,WebSecurity只有一个configurers,就是用户自己定义的WebSecurityConfigurerAdapter performBuild就是WebSecurity执行HttpSecurity的build方法。里面也是相同三流程,不过HttpSecurity里面有很多个configurers,包括RememberMeConfigure,FormLoginConfigure等 18.3.2 WebSecurityConfigurerAdapter
public abstract class WebSecurityConfigurerAdapter implements WebSecurityConfigurer<WebSecurity> { public void init(WebSecurity web) throws Exception { HttpSecurity http = this.getHttp(); web.addSecurityFilterChainBuilder(http).postBuildAction(() -> { FilterSecurityInterceptor securityInterceptor = (FilterSecurityInterceptor)http.getSharedObject(FilterSecurityInterceptor.class); web.securityInterceptor(securityInterceptor); public void configure(WebSecurity web) throws Exception { protected void configure(HttpSecurity http) throws Exception { this.logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity)."); http.authorizeRequests((requests) -> { ((ExpressionUrlAuthorizationConfigurer.AuthorizedUrl)requests.anyRequest()).authenticated(); http.formLogin(); http.httpBasic(); protected final HttpSecurity getHttp() throws Exception { if (this.http != null) { return this.http; } else { AuthenticationEventPublisher eventPublisher = this.getAuthenticationEventPublisher(); this.localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher); AuthenticationManager authenticationManager = this.authenticationManager(); this.authenticationBuilder.parentAuthenticationManager(authenticationManager); Map<Class<?>, Object> sharedObjects = this.createSharedObjects(); this.http = new HttpSecurity(this.objectPostProcessor, this.authenticationBuilder, sharedObjects); if (!this.disableDefaults) { this.applyDefaultConfiguration(this.http); ClassLoader classLoader = this.context.getClassLoader(); List<AbstractHttpConfigurer> defaultHttpConfigurers = SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, classLoader); Iterator var6 = defaultHttpConfigurers.iterator(); while(var6.hasNext()) { AbstractHttpConfigurer configurer = (AbstractHttpConfigurer)var6.next(); this.http.apply(configurer); this.configure(this.http); return this.http;
WebSecurityConfigurerAdapter的内容有:
init方法里面,getHttp,进而进入protected void configure(HttpSecurity http)方法,这个方法经常被用户自身覆盖来设置自己的HttpSecurity。 configure方法里面默认为空 performBuild是通过,在init方法中web.addSecurityFilterChainBuilder将HttpSecurity添加进去。使得WebSecurity的performBuild能执行HttpSecurity的build方法。 在这里,我们就能很清楚地看到。
用户通过覆盖protected void configure(HttpSecurity http)方法来配置HttpSecurity,从而给HttpSecurity添加各种SecurityConfigurer WebSecurity的performBuild会执行HttpSecurity的build HttpSecurity的build会执行它各种SecurityConfigurer的init和configure 18.3.3 HttpSecurity
public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity> implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> { public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception { return (FormLoginConfigurer)this.getOrApply(new FormLoginConfigurer()); private <C extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>> C getOrApply(C configurer) throws Exception { C existingConfig = (SecurityConfigurerAdapter)this.getConfigurer(configurer.getClass()); return existingConfig != null ? existingConfig : this.apply(configurer); public <C extends SecurityConfigurerAdapter<O, B>> C apply(C configurer) throws Exception { configurer.addObjectPostProcessor(this.objectPostProcessor); configurer.setBuilder(this); this.add(configurer); return configurer;
HttpSecurity执行HttpSecurity的时候,就是创建一个FormLoginConfigurer,并将它加入configurers
18.3.4 FormLoginConfigurer
public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractAuthenticationFilterConfigurer<H, FormLoginConfigurer<H>, UsernamePasswordAuthenticationFilter> { public FormLoginConfigurer() { super(new UsernamePasswordAuthenticationFilter(), (String)null); this.usernameParameter("username"); this.passwordParameter("password"); public void init(H http) throws Exception { super.init(http); this.initDefaultLoginFilter(http);FormLoginConfigurer在构造的时候,就初始化了一个UsernamePasswordAuthenticationFilter,它继承自AbstractAuthenticationFilterConfigurer
public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter> extends AbstractHttpConfigurer<T, B> { public void init(B http) throws Exception { this.updateAuthenticationDefaults(); this.updateAccessDefaults(http); this.registerDefaultAuthenticationEntryPoint(http); public void configure(B http) throws Exception { PortMapper portMapper = (PortMapper)http.getSharedObject(PortMapper.class); if (portMapper != null) { this.authenticationEntryPoint.setPortMapper(portMapper); RequestCache requestCache = (RequestCache)http.getSharedObject(RequestCache.class); if (requestCache != null) { this.defaultSuccessHandler.setRequestCache(requestCache); this.authFilter.setAuthenticationManager((AuthenticationManager)http.getSharedObject(AuthenticationManager.class)); this.authFilter.setAuthenticationSuccessHandler(this.successHandler); this.authFilter.setAuthenticationFailureHandler(this.failureHandler); if (this.authenticationDetailsSource != null) { this.authFilter.setAuthenticationDetailsSource(this.authenticationDetailsSource); SessionAuthenticationStrategy sessionAuthenticationStrategy = (SessionAuthenticationStrategy)http.getSharedObject(SessionAuthenticationStrategy.class); if (sessionAuthenticationStrategy != null) { this.authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy); RememberMeServices rememberMeServices = (RememberMeServices)http.getSharedObject(RememberMeServices.class); if (rememberMeServices != null) { this.authFilter.setRememberMeServices(rememberMeServices); SecurityContextConfigurer securityContextConfigurer = (SecurityContextConfigurer)http.getConfigurer(SecurityContextConfigurer.class); if (securityContextConfigurer != null && securityContextConfigurer.isRequireExplicitSave()) { SecurityContextRepository securityContextRepository = securityContextConfigurer.getSecurityContextRepository(); this.authFilter.setSecurityContextRepository(securityContextRepository); F filter = (AbstractAuthenticationProcessingFilter)this.postProcess(this.authFilter); http.addFilter(filter);
AbstractAuthenticationFilterConfigurer的init函数没啥做的,configure函数就是设置各种成员变量,然后使用http.addFilter将UsernamePasswordAuthenticationFilter加进http里面
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]; PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries); 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())); PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date()); 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 许可协议,转载必须注明出处! 2021-05-30-《有效的单元测试》读书笔记 2021-05-29-SpringBoot的经验汇总 2021-06-13-Flyway工具经验汇总 2021-05-14-《Hibernate实战第二版》读书笔记 2021-06-17-Java性能分析工具
彷徨的松球 · 跨域问题详解 - D13 - 博客园 6 天前 |
酷酷的便当 · 深入了解 cURL:你必须知道的命令行数据传输工具 1 周前 |
追风的烤土司 · 从Control Hub访问专用实例应用程序 2 周前 |
想出国的钱包 · VBA-ExcelHome 2 月前 |
暴走的骆驼 · Hello, NilEra :-) 4 月前 |