一直有人問,為什么我實現的共享session不能單點登錄,今天我也抽時間准備好好說一下。
我要噴(別噴我)
首先,網上水貨文章很多,CSDN居多。CSDN轉載率很高,也就是說同相同文章有很多,換湯不換葯的,貼上去不講清楚的,直接復制粘貼連排版都懶得排的。有時候看到這樣的文章我會覺得你只是個學生或者在公司實習的學生。只是匆忙做了個筆記而已。
比如:只貼出一個xml配置,一個java類的,就敢說自己實現了單點登錄,說明文字沒有,點進來無疑浪費了生命中的幾分鍾。https://blog.csdn.net/weixin_40750117/article/details/78684109
還有這個,想讀懂需要與作者換位思考,作者確實逆天了,因為只有他自己能照這個教程搭建成功。https://my.oschina.net/u/1782542/blog/1925940
【 提供一個HTTP接口,讓各個系統都放入到filter里面】由誰來提供?是獨立的嗎?系統A登陸完成,我要訪問系統B,具體流程又是什么樣?不要讓讀者猜你想說啥
還有這個,全文讀下來,他想表達的只是一個系統的“單點”登錄,我不知道那兩個贊怎樣拿到的。https://blog.csdn.net/luckyxl029/article/details/80625461
系統A按照他的邏輯走下來,之后另外一個系統B呢?他拿着token怎樣去和 key為登錄賬號,value為token的數據做匹配?流程說的太簡單了,只有自己能體會其中的奧妙。
等等類似的文章真的數不勝數
共享session
這個東西是在分布式集群環境下誕生的,我之前也解釋過。最典型的情況就是負載均衡:
原來單體應用,部署簡單,隨着訪問量增加,一台服務器爆炸了
好,那就做負載均衡吧
看起來沒毛病,后來大家發現一個問題,用戶登錄成功,負載均衡到1上面,用戶刷新了一下,結果負載均衡到2上面,而2上面沒有用戶登錄的信息,要重新登錄!用戶可能要罵人了,什么狗逼玩意。后來想出一個方法,用戶登錄完成之后,在服務集群之間做session同步就好了,但是這種方式成本比較高。最后采用把session存儲在redis上,統一管理,以實現“無狀態”。
每次驗證都去redis里面拿session信息,如果有就直接登陸。沒有就要求用戶去手動登錄,然后把session信息同步到redis。
衍生問題【科普】
很多人也這樣玩了,但是他是這樣玩的
然后就問了,為啥!!為啥!!為啥!!!我系統A登陸了,切換另一個系統B又要登錄!!!fuck you
好,你fuck我吧
我就問你,系統A生成的sessionId和系統B生成的sessionId能一樣嗎???你能拿着肯德基的會員卡去麥當勞享受優惠嗎?
你這不是單點登錄嗎?
共享session不是單點登錄好吧,應付的場景就不一樣啊。單點登錄能解決共享session的問題,但是共享session解決不了單點登錄的問題。
網上有人實現了!Spring+Shiro+Redis
關於這部分文章我也看了,有的說的蠻有道理,有的說的天真無邪,但還有一個共同點:按照他的教程我無法實現。不過我也小小研究了一下,如果不用單獨的認證中心,應該可以做到“簡單”的單點登錄,但是這個模型有限制,並且不知道有沒有bug
清晰圖:https://www.processon.com/view/link/59a4ee86e4b0afafe7a8213c
這個呢,是我在之前共享Session代碼上加的,但是我已經屏蔽掉共享Session的影響,即沒有Session的任何關系,無論你把Session放在哪里也不會影響,因為這個是基於token的實現。下面是關鍵代碼:
SSOFilter
import com.alibaba.fastjson.JSONObject; import com.example.app.common.Constant; import com.example.app.common.entity.User; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.subject.Subject; import org.apache.shiro.web.filter.AccessControlFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.concurrent.TimeUnit; public class SSOFilter extends AccessControlFilter { @Autowired private StringRedisTemplate stringRedisTemplate; @Override protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; String token = null; User user = null; for(Cookie c : request.getCookies()){ if (Constant.SSO_TOKEN.equals(c.getName())){ token = c.getValue(); break; } } Subject subject = SecurityUtils.getSubject(); boolean authenticated = subject.isAuthenticated();// 是否通過身份驗證 if (token != null){ // 如果是登出操作,需要清除公共token信息 if(request.getRequestURI().equals("/logout")){ stringRedisTemplate.delete(Constant.TOKEN_PRE + token); subject.logout(); return true; } String s = stringRedisTemplate.boundValueOps(Constant.TOKEN_PRE + token).get(); user = JSONObject.parseObject(s, User.class);// 根據token獲取用戶信息 if (user != null){ // 有用戶信息並且沒有身份認證 if(!authenticated){ // 手動通過,因為在其它系統已經登錄 subject.login(new UsernamePasswordToken(user.getUsername(), user.getPassword())); } }else{ // 沒有用戶信息,說明已經超時或者退出登錄,需要清除當前的認證信息 if (authenticated){ subject.logout(); } } } return true; } @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { return false; } }
大家可以看到,這個過濾器無論如何都會返回true,為什么呢,因為我只需要輔助判斷用戶是否已經登錄就可以了,其它的流程按照正常走。
下面是shiro配置
@Bean(name = "ssoFilter") public SSOFilter ssoFilter(){ return new SSOFilter(); } /** * 6. 配置ShiroFilter * @return */ @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilterFactoryBean(){ LinkedHashMap<String, String> map = new LinkedHashMap<>(); // 靜態資源 map.put("/css/**", "anon"); map.put("/js/**", "anon"); // 公共路徑 map.put("/login", "anon"); map.put("/register", "anon"); //map.put("/*", "anon"); // 登出,項目中沒有/logout路徑,因為shiro是過濾器,而SpringMVC是Servlet,Shiro會先執行 // map.put("/logout", "logout"); // 授權 map.put("/user/**", "authc,roles[user]"); map.put("/admin/**", "authc,roles[admin]"); // everything else requires authentication: map.put("/**", "ssoFilter,authc"); ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); // 配置SecurityManager factoryBean.setSecurityManager(securityManager()); // 配置權限路徑 factoryBean.setFilterChainDefinitionMap(map); // 配置登錄url factoryBean.setLoginUrl("/"); // 配置無權限路徑 factoryBean.setUnauthorizedUrl("/unauthorized"); return factoryBean; } /** * 解決:org.apache.shiro.UnavailableSecurityManagerException: No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.ThreadContext * or as a vm static singleton. This is an invalid application configuration. * SSOFilter.isAccessAllowed(SSOFilter.java:44) ~[classes/:na] * @return */ @Bean public FilterRegistrationBean delegatingFilterProxy(){ FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); DelegatingFilterProxy proxy = new DelegatingFilterProxy(); proxy.setTargetFilterLifecycle(true); proxy.setTargetBeanName("shiroFilter"); filterRegistrationBean.setFilter(proxy); return filterRegistrationBean; }
公共的常量
public class Constant { public static final String TOKEN_PRE = "loginToken:"; // token前綴 public static final String SSO_TOKEN = "SSO_TOKEN"; // token的cookie名稱 }
登錄代碼
@RequestMapping("/login") public BaseResponse<String> login(@RequestBody User user, HttpServletResponse httpServletResponse){ BaseResponse<String> response = new BaseResponse<>(0,"登陸成功"); Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken( user.getUsername(), user.getPassword()); subject.login(usernamePasswordToken); response.setData("/home"); // 登陸成功之后,將token放入cookie String token = UUID.randomUUID().toString(); Cookie cookie = new Cookie(Constant.SSO_TOKEN, token); cookie.setPath("/"); cookie.setMaxAge(60*30); httpServletResponse.addCookie(cookie); // 放入redis userService.addTokenInfo(token, new User(user.getUsername(), user.getPassword())); return response; }
限制:
1. 與token綁定的User需要是公共資源,這樣才能被多系統共用,因為有序列化反序列化的過程。
2. 子系統最好是同一個域下,不能跨域
測試:分別打開兩個系統
....
登錄其中一個系統
然后直接訪問另一個系統的權限資源
為了避免是共享session導致的,我已經關閉了共享session,看:
.....
sessionId不一樣,也照樣能完成單點登錄操作。
我們再來看redis存儲的token信息,當token超時清除后
刷新一下前台頁面,立即返回登錄界面
單點登錄
關於單點登錄的說明網上有很多,關鍵就在於獨立的認證中心和身份標識token(sessionId不安全)
認證流程不由應用本身負責,而是統一去認證中心走流程,通過后會給你一張通行證token,巴拉巴拉巴拉~不想說了,以后再說