SSO單點登入系統的設計與實現
之前在專案中用到了單點登入系統來解決分散式環境中Session共享的問題,趁著現在閒了,總結一下......
什麼是sso系統
SSO英文全稱Single Sign On,單點登入。SSO是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統。它包括可以將這次主要的登入對映到其他應用中用於同一個使用者的登入的機制。它是目前比較流行的企業業務整合的解決方案之一。
為什麼要有單點登入系統
針對這個問題,我就拿現有的一個專案說一下吧,這個專案不僅涉及到叢集,還涉及到了分散式。
先不說分散式,就單單拿叢集來說,就會存在一個問題,比如,我這次訪問網站,進行了登入,過一會之後我訪問個人中心,Nginx將我的請求發到了另一臺伺服器,這時就出現問題了,這臺伺服器中並沒沒有儲存我的登入狀態,因而我需要重新登入,然後我又訪問一次個人中心,Nginx又將我的請求傳送到另一臺伺服器,然後...又提醒我登入,這當然是不能忍的,對於這種情況,除了搭建單點登入系統,還有一個解決方案,就是在Tomcat中配置Session複製,配置好了之後,tomcat會以廣播的形式共享Session資訊,但是這大大地增加了Tomcat的壓力,而且存在一個問題,當你的叢集中節點數量不斷增加,就會出現問題,session共享太佔用系統資源了,所以一般不選擇這個作為解決方案。分散式環境下的登入問題就更不用說了,和叢集中類似......這個時候我們就需要搭建一個單點登入系統,提供一個介面,共其他模組呼叫,以檢測使用者登入狀態。
SSO單點登入系統說白了就是使用redis模擬Session(Key-Value),實現Session的統一管理。
具體實現
SSO表現層
定義了三個處理器,分別用於註冊、登入、外部呼叫,檢視使用者登入狀態:
RegisterController.java
package cn.e3mall.sso.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import cn.e3mall.common.utils.E3Result; import cn.e3mall.pojo.TbUser; import cn.e3mall.sso.service.RegisterService; /** * 註冊功能Controller */ @Controller public class RegisterController { @Autowired private RegisterService registerService; @RequestMapping("/page/register") public String showRegister() { return "register"; } @RequestMapping("/user/check/{param}/{type}") @ResponseBody public E3Result checkData(@PathVariable String param, @PathVariable Integer type) { return registerService.checkData(param, type); } @RequestMapping(value="/user/register", method=RequestMethod.POST) @ResponseBody public E3Result saveUser(TbUser user) { return registerService.saveUser(user); } }
這個沒什麼好說的,就是呼叫服務進行使用者註冊,將註冊資訊插入資料庫中。
LoginController.java
package cn.e3mall.sso.controller; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import cn.e3mall.common.utils.CookieUtils; import cn.e3mall.common.utils.E3Result; import cn.e3mall.sso.service.LoginService; @Controller public class LoginController { @Autowired private LoginService loginService; @Value("${TOKEN_KEY}") private String TOKEN_KEY; @RequestMapping("/page/login") public String showLogin() { return "login"; } @RequestMapping(value="/user/login", method=RequestMethod.POST) @ResponseBody public E3Result login(String username, String password, HttpServletRequest request, HttpServletResponse response) { E3Result e3Result = loginService.userLogin(username, password); // 判斷是否登入成功 if(e3Result.getStatus() == 200) { String token = (String) e3Result.getData(); CookieUtils.setCookie(request, response, TOKEN_KEY, token);// 將token儲存在cookie中,瀏覽器關閉即失效(類似sessionid) } // 如果登入成功,需要將token寫入cookie return e3Result; } }
這裡呼叫了使用者登入服務:
@Override
public E3Result userLogin(String username, String password) {
// 1、判斷使用者名稱和密碼是否正確
TbUserExample example = new TbUserExample();
Criteria criteria = example.createCriteria();
criteria.andUsernameEqualTo(username);
// 執行查詢
List<TbUser> userList = userMapper.selectByExample(example);
if (userList == null || userList.size() == 0) {
// 返回登入失敗
return E3Result.build(400, "使用者名稱或密碼錯誤");
}
// 取使用者資訊
TbUser user = userList.get(0);
if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(user.getPassword())) {
// 返回登入失敗
return E3Result.build(400, "使用者名稱或密碼錯誤");
}
// 3、如果正確生成token
String token = UUID.randomUUID().toString();
// 4、把使用者資訊寫入redis,key:token value:使用者資訊
user.setPassword(null);
jedisClient.set("SESSION:" + token, JsonUtils.objectToJson(user));
jedisClient.expire("SESSION:" + token, SESSION_EXPIRE);
// 5、把token返回
return E3Result.ok(token);
}
這樣,登入成功的話,服務層在Redis中會存在一個String型別的資料,並且這個資料設定了生存時間(30分鐘),模擬了Session的生存時間,資料的key為SESSION:隨機串,模仿的是sessionid,value為使用者的資訊(不包含密碼)。表現層將服務層返回的token儲存在cookie中,用於後續查詢使用者登入狀態以及登入使用者資訊。
另外需要注意的是,cookie也存在跨域問題,即不能跨三級域名,但是可以跨二級域名,比如sso.code4j.cn和www.code4j.cn和search.code4j.cn之間的cookie是互通的,需要設定一下cookie.setDomain(.code4j.cn);即可
TokenController.java
package cn.e3mall.sso.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.common.utils.JsonUtils;
import cn.e3mall.sso.service.TokenService;
/**
* 根據token查詢使用者資訊Controller
*
* @author Ldd
*
*/
@Controller
public class TokenController {
@Autowired
private TokenService tokenService;
/*
@RequestMapping(value = "/user/token/{token}", produces = "application/json;charset=utf-8")
@ResponseBody
public String getUserByToken(@PathVariable String token, String callback) {
E3Result result = tokenService.getUserByToken(token);
// 響應結果之前判斷是否為jsonp請求
if (StringUtils.isNotBlank(callback)) {
// 把結果封裝成一個JS語句響應
return callback + "(" + JsonUtils.objectToJson(result) + ");";
}
return JsonUtils.objectToJson(result);
}
*/
@RequestMapping("/user/token/{token}")
@ResponseBody
public Object getUserByToken(@PathVariable String token, String callback) {
E3Result result = tokenService.getUserByToken(token);
// 響應結果之前判斷是否為jsonp請求
if (StringUtils.isNotBlank(callback)) {
// 響應Jsonp請求(使用於4.1版本以上的Spring)
MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
mappingJacksonValue.setJsonpFunction(callback);
return mappingJacksonValue;
}
return result;
}
}
這裡呼叫了服務層的getUserByToKen方法:
@Override
public E3Result getUserByToken(String token) {
// 從redis中獲取使用者資訊
String user_json = jedisClient.get("SESSION:" + token);
// 判斷
if(StringUtils.isBlank(user_json)) {
return E3Result.build(201, "使用者登入資訊已過期");
} else {
TbUser user = JsonUtils.jsonToPojo(user_json, TbUser.class);
// 重置過期時間
jedisClient.expire("SESSION:" + token, SESSION_EXPIRE);
// 返回結果
return E3Result.ok(user);
}
}
這個處理器用於外部呼叫以檢查使用者登入狀態,並獲取使用者登入資訊,外部以/user/token/xxxxxxx的形式進行訪問,xxxxx即為本地cookie中的token(sessionid),然後服務層從Redis中查詢,查詢到則表示使用者已經登入,然後返回使用者資訊,並重置Redis中該資料的生存時間。
呼叫SSO單點登入系統
具體需求:
1.當用戶登入之後,本地cookie中就會存在token資訊。
2.從cookie中取token並根據token查詢使用者資訊。
3.把使用者名稱展示到首頁
解決方案:
一、在Controller中取cookie中的token資料,呼叫sso服務查詢使用者資訊
二、當頁面載入完成後使用js取token的資料,使用ajax請求查詢使用者資訊
這裡我決定選擇第二個方案,應為工程很多,如果使用第一種方案,需要更改每一個工程的Controller,而使用第二種解決方案,寫一個js檔案,然後在需要的地方引用即可,但是這裡就存在一個問題,我們的服務介面在sso系統中sso.code4j.cn, 而首頁的域名是www.code4j.cn使用ajax請求跨域了,而Js不可以跨域請求資料
什麼是跨域:
1.域名不同
2.域名相同但埠不同
Js跨域的解決方案:Jsonp
Jsonp並不是什麼新技術,而是跨域的解決方案。使用js的特性:Js可以跨域載入js檔案,繞過跨域請求。
Jsonp原理:
看起來很複雜,其實真正使用的時候並不用這麼麻煩,因為Jquery已經封裝好了......
具體實現
客戶端
var E3MALL = {
checkLogin : function(){
var _ticket = $.cookie("token");//獲取cookie中的資訊,使用了jquery.cookie.js
if(!_ticket){
return ;
}
$.ajax({
url : "http://localhost:8088/user/token/" + _ticket,
dataType : "jsonp",// 表示跨域請求,加了這個,上面圖示的一些動作jq就會自動完成,你只需在處理器中接收callback然後處理即可
type : "GET",
success : function(data){
if(data.status == 200){
var username = data.data.username;
var html = username + ",歡迎您!<a href=\"http://www.code4j.cn/user/logout.html\" class=\"link-logout\">[退出]</a>";
$("#loginbar").html(html);
}
}
});
}
}
$(function(){
// 檢視是否已經登入,如果已經登入查詢登入資訊
E3MALL.checkLogin();
});
這樣就行了,獲取到使用者資訊,就展示在首頁頂部,沒有接收到使用者資訊,就不做處理,還顯示原來的登入按鈕。
當然,這只是一個最基礎的示範,具體業務還需具體分析,具體實現......