之前在項目中用到了單點登入系統來解決分布式環境中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();
});
這樣就行了,擷取到使用者資訊,就展示在首頁頂部,沒有接收到使用者資訊,就不做處理,還顯示原來的登入按鈕。
當然,這隻是一個最基礎的示範,具體業務還需具體分析,具體實作......