[單點(diǎn)登錄](Single Sign On)丸冕,簡稱為 SSO,是目前比較流行的企業(yè)業(yè)務(wù)整合的解決方案之一叠纷。SSO的定義是在多個應(yīng)用系統(tǒng)中窖贤,用戶只需要登錄一次就可以訪問所有相互信任的應(yīng)用系統(tǒng)。
很早期的公司草雕,一家公司可能只有一個Server巷屿,慢慢的Server開始變多了。每個Server都要進(jìn)行注冊登錄墩虹,退出的時候又要一個個退出嘱巾。用戶體驗(yàn)很不好!
這就是今天要說的單點(diǎn)登錄
登錄的原理
前因
由于 http 是無狀態(tài)的败晴,所以每次請求都相當(dāng)于是新的浓冒,那么系統(tǒng)是怎么記住的呢,這個時候 session 和 cookie這兩個東西出現(xiàn)了尖坤。第一次訪問 server 稳懒,tomcat會生成一個 cookie,記錄 sessionId 并且返回到瀏覽器,比如你訪問你的是 a.com,那么第一次訪問 a.com之后场梆,瀏覽器就會多了一個 cookie墅冷,這個 cookie 的作用域就是 a.com,下次訪問 a.com 的時候就會自動帶上這個 cookie或油,然后服務(wù)器判斷是不是第一次訪問寞忿。cookie 和 session 是相輔相成的。
后果
判斷登錄是否成功有兩種方法:
第一種是:判斷 cookie 中帶的 sessionId 我服務(wù)器存不存在顶岸,存在成功腔彰,反之失敗。
第二種是:第一次登錄成功之后給當(dāng)前 session 設(shè)個值辖佣,然后每次訪問進(jìn)來都判斷進(jìn)來的 sessionId 霹抛,用這個 sessionId 能拿到值則登錄成功,反之失敗卷谈。
流程
單點(diǎn)登錄原理
- 瀏覽器訪問應(yīng)用1中的受限資源
- 判斷沒登錄杯拐,則跳轉(zhuǎn)到認(rèn)證中心登錄頁面
- 登錄成功,跳轉(zhuǎn)回應(yīng)用1世蔗,附帶登錄成功的 token
- 應(yīng)用通過 http 請求驗(yàn)證 token 是否正確有效
- token 有效
- 返回受限資源端逼,允許訪問
- 訪問應(yīng)用2中的受限資源
- 應(yīng)用通過 http 請求驗(yàn)證是否已登錄
- 返回已登錄
- 返回受限資源,允許訪問
細(xì)心的人可能會問污淋,在第8步的時候顶滩,應(yīng)用2通過什么判斷是否已登錄,如果不同域名的話芙沥,傳不過 cookie诲祸,這個問題的我只能想到兩種解決方案:
- 應(yīng)用1和應(yīng)用2保持同一個二級域名,這樣就可以實(shí)現(xiàn) cookie 共享了而昨。例如:app1.sso.com救氯,app2.sso.com
- 如果非要跨域的話,我覺得站內(nèi)跳轉(zhuǎn)是可行的歌憨,假如有 app1.com 和 app2.com着憨, 在 app1.com 中加個跳轉(zhuǎn)的按鈕,點(diǎn)擊這個按鈕執(zhí)行的還是 app1.com 的接口务嫡,在接口中跳轉(zhuǎn)到 app2.com甲抖,跳轉(zhuǎn)的時候根據(jù)當(dāng)前 cookie拿到用戶信息,這樣在 app2.com 認(rèn)證成功之后也可設(shè)置 app2.com 的 cookie心铃。
- 還有一種方式就是通過父域的方式准谚,cookie 存放在 app.com路徑 中,app1和 app2通過 iframe 嵌套在 app 中去扣,這樣怎么跳都可以拿到 app 的 cookie柱衔,這是我在別的地方看到的,好像是可行。
UML 圖
這次我通過了共享 cookie實(shí)現(xiàn)的單點(diǎn)登錄:
有個這個 uml 圖就相對比較清晰了唆铐,自己完善個代碼基本不難了
因?yàn)樯婕暗綍捁芾碚芷荩行?session 什么的需要處理,所以我在這邊接管了框架的 session 的存儲艾岂,也就是實(shí)現(xiàn)了HttpSessionListener監(jiān)聽器顺少,監(jiān)聽 session 的創(chuàng)建和刪除,加了一個 map 王浴,根據(jù) sessionId 來存儲和管理脆炎。
在 web.xml 中增加監(jiān)聽的配置
在判斷是否登錄的時候,我可以直接調(diào)用getSession根據(jù) sessionId來拿到HttpSession 對象氓辣,就像這樣:
Object auth =LocalSessionManager.getSession(session.getId()).getAttribute("token_info");
來判斷當(dāng)前應(yīng)用是否有沒有setAttribute值腕窥,有就是登錄過,空就是沒登錄筛婉。這個作用就是防止每次判斷是否登錄都要去認(rèn)證中心,那樣認(rèn)證中心壓力太大了癞松。
關(guān)鍵代碼
我本地設(shè)置的 hosts:
127.0.0.1 app1.sso.com
127.0.0.1 app2.sso.com
127.0.0.1 server.sso.com
app1端口是8083
app2端口是8084
server端口是8082
也在此奉上關(guān)鍵代碼:
- sso-client的攔截器
/**
* springmvc 攔截器
* @param request
* @param response
* @param o
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
String url = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getRequestURI();
HttpSession session = request.getSession();
Object auth = LocalSessionManager.getSession(session.getId()).getAttribute("token_info");
String token = request.getParameter("token");
//loginOut 不攔截
if (request.getRequestURI().contains("loginOut") || request.getRequestURI().contains("toLoginOut")) {
return true;
}
String userName = UrlUtils.getUserNameByCookies(request.getCookies());
//說明當(dāng)前用戶為登錄狀態(tài)
if (auth != null) {
return true;
}
//說明是sso服務(wù)器調(diào)用的爽撒,稍后可能會改成別的判斷
if (token != null) {
Map<String, Object> param = new HashMap<>();
param.put("token", token);
param.put("appUrl", request.getServerName() + ":" + request.getServerPort());
String result = HttpUtils.httpPostRequest(serverUrl + "user/checkToken", param);
if (resultHandle(result, request, response)){
return true;
}
}
//說明當(dāng)前用戶 有 cookie,可能認(rèn)證中心已經(jīng)登錄了
if (auth == null && userName != null) {
//判斷sso服務(wù)器是否已經(jīng)登錄了
Map<String, Object> params = new HashMap<>();
params.put("appSessionId", session.getId());
params.put("appUrl", request.getServerName() + ":" + request.getServerPort());
params.put("username", userName);
String result1 = HttpUtils.httpPostRequest(serverUrl + "user/checkLogin", params);
if (resultHandle(result1, request, response)){
return true;
}
}
response.sendRedirect(serverUrl + "?back=" + UrlUtils.encodeUrlWithSessionId(url, session.getId()));
return false;
}
/**
* 處理 server 返回的內(nèi)容
* @param result
* @param request
* @param response
* @return
*/
private boolean resultHandle(String result, HttpServletRequest request, HttpServletResponse response) {
JSONObject jsonResult = JSONObject.parseObject(result);
if (jsonResult.getBoolean("success")) {
UserInfo info = JSONObject.parseObject(jsonResult.getString("data"), UserInfo.class);
HttpSession sessionLocal = LocalSessionManager.getSession(info.getLocalSessionId());
if (sessionLocal != null) {
Cookie cookie = new Cookie("_token_security", UrlUtils.encodeUrl(Base64Utils.encodeBase64(info.getUserName())));
cookie.setPath("/");
cookie.setDomain(".sso.com");
response.addCookie(cookie);
System.out.println("url:" + request.getServerName());
sessionLocal.setAttribute("token_info", info);
return true;
}
return true;
}
return false;
}
- sso-server 的登錄 controller
@RequestMapping(value = "/login", method = RequestMethod.GET)
@ResponseBody
public void login(String username, String password, String target,
HttpServletRequest request, HttpServletResponse response) throws IOException {
if (username == null) {
username = request.getParameter("name");
}
if (password == null) {
password = request.getParameter("pwd");
}
if (target == null) {
target = request.getParameter("target");
}
User user = userService.login(username, password);
if (user != null) {
String appSessionId = target.split("&")[1];
UserInfo info = new UserInfo(request.getSession().getId(), appSessionId, username, null);
String token = TokenUtils.takeTokenWithUserInfo(info);
response.sendRedirect(URLUtils.decodeUrl(target.split("&")[0]) + "?token=" + token);
return;
}
response.sendRedirect("/login.jsp?target=" + target);
}
@RequestMapping(value = "/checkToken", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> checkToken(String token, String appUrl) {
Map<String, Object> map = new HashMap();
map.put("success", false);
UserInfo user = TokenUtils.getUserInfo(token);
if (user != null) {
user.setAppUrl(appUrl);
//存入用戶和應(yīng)用的關(guān)聯(lián)
UserAppManager.add(user.getUserName(), appUrl);
//重新初始化 token响蓉,是剛才的 token 失效硕勿,目的是 token 驗(yàn)證只能用一次
TokenUtils.updataTokenWithUserInfo(token, user);
map.put("success", true);
map.put("data", user);
}
return map;
}
@RequestMapping(value = "/checkLogin", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> checkLogin(String appUrl, String username,String appSessionId, HttpServletRequest request, HttpServletResponse response) {
username = Base64Utils.decodeBase64(URLUtils.decodeUrl(username));
Map<String, Object> map = new HashMap();
map.put("success", false);
UserInfo user = TokenUtils.getUserInfoByUserName(username);
//當(dāng)前賬號已登錄
if (user != null) {
//當(dāng)前應(yīng)用沒登錄
if (TokenUtils.getUserInfoByNameUrl(username,appUrl)==null){
UserInfo info=new UserInfo(user.getGloalSessionId(),appSessionId,username,appUrl);
//存入用戶和應(yīng)用的關(guān)聯(lián)
UserAppManager.add(username, appUrl);
TokenUtils.takeTokenWithUserInfo(info);
map.put("data", info);
map.put("success", true);
return map;
}else {
map.put("data", TokenUtils.getUserInfoByNameUrl(username,appUrl));
map.put("success", true);
return map;
}
}
return map;
}
@RequestMapping(value = "/loginOut", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> loginOut(String username) {
username = Base64Utils.decodeBase64(URLUtils.decodeUrl(username));
Map<String, Object> map = new HashMap();
map.put("success", false);
try {
Set<String> urls = UserAppManager.getByName(username);
if (urls != null) {
for (String url : urls) {
//遠(yuǎn)程刪除 session
UserInfo info = TokenUtils.getUserInfoByNameUrl(username, url);
Map<String, Object> params = new HashMap();
params.put("sessionId", info.getLocalSessionId());
String targetUrl = "http://" + url + "/user/toLoginOut";
HttpUtils.httpPostRequest(targetUrl, params);
}
}
TokenUtils.deleteByName(username);
UserAppManager.deleteByName(username);
map.put("success", true);
return map;
} catch (Exception e) {
logger.error("loginOut error",e);
}
return map;
}
github 地址:
https://github.com/thecattle/sso-client
https://github.com/thecattle/sso-server
ps:自己瞎寫的,要是有大佬看到枫甲,還請多多指出錯誤