單點登入系統(SSO)的實現
一、SSO技術簡介
1、基本介紹
目前的企業應用環境中,往往有很多的應用系統,如辦公自動化(OA)系統,財務管理系統,檔案管理系統,資訊查詢系統等等。這些應用系統服務於企業的資訊化建設,為企業帶來了很好的效益。但是,使用者在使用這些應用系統時,並不方便。使用者每次使用系統,都必須輸入使用者名稱稱和使用者密碼,進行身份驗證;而且,應用系統不同,使用者賬號就不同,使用者必須同時牢記多套使用者名稱稱和使用者密碼。特別是對於應用系統數目較多,使用者數目也很多的企業,這個問題尤為突出。問題的原因並不是系統開發出現失誤,而是缺少整體規劃,缺乏統一的使用者登入平臺。
SSO(Single Sign-On,單點登入)是身份管理中的一部分。SSO的一種較為通俗的定義是:SSO是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統。它包括可以將這次主要的登入對映到其他應用中用於同一個使用者的登入的機制。它是目前比較流行的企業業務整合的解決方案之一。
2、SSO解決的問題
我們在做SSO之前首先要明白為什麼要有單點登入,即SSO在解決什麼問題?那麼我們先來看一下傳統的登入實現方式:
以上就是傳統的登入實現方式,但是在併發量高的情況下呢?比如現在有 2000~3000 的併發,這時一個tomcat 不能滿足業務需求,需要做叢集。如下:
那麼現在就會出現Session共享的問題(tomcat做叢集配置session複製。如果叢集中節點很多,會形成網路風暴。推薦節點數量不要超過5個)。此外在分散式架構中,我們會把系統拆分成多個子系統,在這些子系統之間進行跳轉時也會出現session不能共享的問題(和上述類似)。
上述這些情況正是SSO要解決的問題!
3、使用SSO的好處
- 方便使用者
使用者使用應用系統時,能夠一次登入,多次使用。使用者不再需要每次輸入使用者名稱稱和使用者密碼,也不需要牢記多套使用者名稱稱和使用者密碼。單點登入平臺能夠改善使用者使用應用系統的體驗。
- 方便管理員
系統管理員只需要維護一套統一的使用者賬號,方便、簡單。相比之下,系統管理員以前需要管理很多套的使用者賬號。每一個應用系統就有一套使用者賬號,不僅給管理上帶來不方便,而且,也容易出現管理漏洞。
- 簡化應用系統開發
開發新的應用系統時,可以直接使用單點登入平臺的使用者認證服務,簡化開發流程。單點登入平臺通過提供統一的認證平臺,實現單點登入。因此,應用系統並不需要開發使用者認證程式。
4、實現SSO的技術
- 基於cookies實現
需要注意如下幾點:如果是基於兩個域名之間傳遞sessionid的方法可能在windows中成立,在unix&linux中可能會出現問題;可以基於資料庫實現;在安全性方面可能會作更多的考慮。另外,關於跨域問題,雖然cookies本身不跨域,但可以利用它實現跨域的SSO。
- Broker-based(基於經紀人)
例如Kerberos等,這種技術的特點就是,有一個集中的認證和使用者帳號管理的伺服器。經紀人給被用於進一步請求的電子的身份存取。中央資料庫的使用減少了管理的代價,併為認證提供一個公共和獨立的”第三方”。例如Kerberos、Sesame、IBM KryptoKnight(憑證庫思想)等。
- Agent-based(基於代理人)
在這種解決方案中,有一個自動地為不同的應用程式認證使用者身份的代理程式。這個代理程式需要設計有不同的功能。比如, 它可以使用口令表或加密金鑰來自動地將認證的負擔從使用者移開。代理人被放在伺服器上面,在伺服器的認證系統和客戶端認證方法之間充當一個”翻譯”。例如SSH等。
- Token-based
例如SecurID、WebID;現在被廣泛使用的口令認證,比如FTP,郵件伺服器的登入認證,這是一種簡單易用的方式,實現一個口令在多種應用當中使用。
- 基於安全斷言標記語言(SAML)實現
SAML(Security Assertion Markup Language,安全斷言標記語言)的出現大大簡化了SSO,並被OASIS批准為SSO的執行標準。開源組織OpenSAML 實現了 SAML 規範,可參考http://www.opensaml.org/。
二、SSO產品介紹
1、SUN SSO技術
1.SUM SSO介紹
SUN SSO技術是Sun Java System Access Manager產品中的一個組成部分。
Sun 的新身份管理產品包括Sun Java System Identity Manager、Sun Java System Directory Server Enterprise Edition 和 Sun Java System Access Manager,以上三者為Sun Java Identity Management Suite (身份識別管理套件)的組成部分,它們與Sun Java Application Platform Suite、Sun Java Availability Suite、Sun Java Communications Suite、Sun Java Web Infrastructure Suite組成Java ES。具有革新意義的這一系列產品提供端到端身份管理,同時可與 60 多種第三方資源和技術實現互操作,整合產品可以從SUN公司網站下載,一般以Agent軟體方式提供,是業內整合程式最高、最為開放的身份管理解決方案之一。
在Sun 的新身份管理產品中,Sun Java System Access Manager是基中的一個重要組成部分,Java Access Manager基於J2EE架構,採用標準的API,可擴充套件性強,具有高可靠性和高可用性,應用是部署在Servlets容器中的,支援分散式,容易部署且有較低的TCO。通過使用集中驗證點、其於角色的訪問控制以及 SSO,Sun Java System Access Manager 為所有基於 Web 的應用程式提供了一個可伸縮的安全模型。它簡化了資訊交換和交易,同時能保護隱私及重要身份資訊的安全。
2.SUN SSO 實現原理
SSO的核心在於統一使用者認證,登入、認證請求通過IDENTITY SERVER伺服器完成,然後分發到相應應用。SUN SSO是java Access Manager的一個組成部分,SSO基於Cookie實現解釋如下:
(1)Policy Agent on Web or Application Server intercepts resource requests and enforces access control; (2)Client is issued SSO token containing information for session Validation with Session service. (3)SSO token has no content- just a long random string used as a handle. (4)Web-based applications use browser session cookies or URL rewriting to issue SSO token. (5)Non Web applications use the SSO API(Java/c) to obtain the SSO token to validate the users identity. |
3.SUN SSO 的應用
這裡說的應用是指Sun Java System Access Manager的應用。成功應用例子很多,包括德國電信等公司的應用,國內也有大量高校在使用,也有相當多的其它行業的應用。
2、CAS技術
1.CAS 背景介紹
CAS(Central Authentication Service),是耶魯大學開發的單點登入系統(SSO,single sign-on),應用廣泛,具有獨立於平臺的,易於理解,支援代理功能。CAS系統在各個大學如耶魯大學、加州大學、劍橋大學、香港科技大學等得到應用。
Spring Framework的Acegi安全系統支援CAS,並提供了易於使用的方案。Acegi安全系統,是一個用於Spring Framework的安全框架,能夠和目前流行的Web容器無縫整合。它使用了Spring的方式提供了安全和認證安全服務,包括使用Bean Context,攔截器和麵向介面的程式設計方式。因此,Acegi安全系統能夠輕鬆地適用於複雜的安全需求。Acegi安全系統在國內外得到了廣泛的應用,有著良好的社群環境。
2.CAS 的設計目標
- 為多個Web應用提供單點登入基礎設施,同時可以為非Web應用但擁有Web前端的功能服務提供單點登入的功能;
- 簡化應用認證使用者身份的流程;
- 將使用者身份認證集中於單一的Web應用,讓使用者簡化他們的密碼管理,從而提高安全性;而且,當應用需要修改身份驗證的業務邏輯時,不需要到處修改程式碼。
3.CAS 的實現原理
CAS(Central Authentication Server)被設計成一個獨立的Web應用。CAS建立一個位數很長的隨機數(ticket)。CAS把這個ticket和成功登入的使用者以及使用者要訪問的service聯絡起來。例如,如果使用者peon重定向自service S,CAS建立ticket T,這個ticket T允許peon訪問service S。這個ticket是個一次性的憑證;它僅僅用於peon和僅僅用於service S,並且只能使用一次,使用之後馬上會過期,即ticket通過驗證,CAS立即刪除該ticket,使它以後不能再使用。這樣可以保證其安全性。
關於ST,在取一個ST時,即使用deleteTicket(ticketId)同時將一次性的ST刪除;而對於TGT或PT,則通過resetTimer(ticketId)以更新TGT或PT的時間。在CAS服務端返回的ST中只能得出使用者名稱。
三、實現單點登入系統
1、SSO業務流程
2、SSO系統建立
首先我們來建立SSO的Maven工程,新增依賴如下:
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jsp-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Redis客戶端 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
</dependencies>
<!-- 新增tomcat外掛 -->
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<configuration>
<port>8084</port>
<path>/</path>
</configuration>
</plugin>
</plugins>
</build>
我們這裡的SSO系統主要提供兩個功能模組,一個是使用者的註冊功能,另一個就是使用者登入相關的功能。下面我們就來分別實現這兩個模組。因為我們這裡採用了mybatis框架,並且持久化層都是簡單的增刪改查操作,所以這裡就直接使用Mybatis的逆向工程來生成Dao的程式碼了。
1.使用者註冊
1)資料校驗介面
Controller只是釋出服務。接收三個引數,一個是要校驗的資料,一個數據型別,一個是callback。呼叫Service校驗。返回json資料。需要支援jsonp,需要判斷callback。
@RequestMapping("/check/{param}/{type}")
@ResponseBody
public Object checkData(
@PathVariable String param,
@PathVariable Integer type,
String callback){
try {
ResultObject result = registerService.checkData(param, type);
if(StringUtils.isNotBlank(callback)){
//請求為jsonp,需要支援
MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
mappingJacksonValue.setJsonpFunction(callback);
return mappingJacksonValue;
}
return result;
} catch (Exception e) {
e.printStackTrace();
return ResultObject.build(500, "資料校驗失敗");
}
}
Service接收兩個引數,一個是要校驗的資料,一個是資料型別。根據不同的資料型別生成不同的查詢條件,到user表中進行查詢如果查詢到結果返回false,查詢結果為空返回true。
public ResultObject checkData(String param, int type) {
//根據資料型別檢測資料
TbUserExample example = new TbUserExample();
Criteria criteria = example.createCriteria();
//1、2、3分別代表username,phone,email-->都不可重複
if(1==type){
criteria.andUsernameEqualTo(param);
}else if (2==type) {
criteria.andPhoneEqualTo(param);
}else if (3==type) {
criteria.andEmailEqualTo(param);
}
//執行查詢
List<TbUser> list = userMapper.selectByExample(example);
//判斷查詢結果是否為空
if(list==null||list.isEmpty()){
return ResultObject.ok(true);
}
return ResultObject.ok(false);
}
2)使用者註冊介面
Controller接收一個表單,請求的方法為post。使用TbUser接收表單的內容。呼叫Service插入資料,返回。
@RequestMapping(value="/register",method=RequestMethod.POST)
@ResponseBody
public ResultObject register(TbUser user){
try {
ResultObject result = registerService.register(user);
System.out.println(result.getStatus()+"===="+result.getMsg());
return result;
} catch (Exception e) {
e.printStackTrace();
return ResultObject.build(500, "使用者註冊失敗");
}
}
Service接收TbUser引數,對資料進行校驗,校驗成功,插入資料,返回結果。
public ResultObject register(TbUser user) {
//校驗資料
//校驗使用者名稱密碼不能為空
if(StringUtils.isBlank(user.getUsername())||StringUtils.isBlank(user.getPassword())){
return ResultObject.build(400, "使用者名稱或密碼不能為空");
}
//校驗資料是否重複
//校驗使用者名稱
ResultObject result = checkData(user.getUsername(), 1);
if(!(boolean) result.getData()){
return ResultObject.build(400, "使用者名稱重複");
}
//校驗手機號
if(user.getPhone()!=null){
result=checkData(user.getPhone(), 2);
if(!(boolean) result.getData()){
return ResultObject.build(400, "手機號重複");
}
}
//校驗郵箱
if(user.getEmail()!=null){
result=checkData(user.getEmail(), 3);
if(!(boolean) result.getData()){
return ResultObject.build(400, "郵箱重複");
}
}
//插入資料
user.setCreated(new Date());
user.setUpdated(new Date());
//密碼MD5加密
user.setPassword(DigestUtils.md5DigestAsHex(user.getPassword().getBytes()));
userMapper.insert(user);
return ResultObject.ok();
}
2.使用者登入
1)使用者登入介面
Controller接收兩個引數,一個是使用者名稱,一個是密碼,請求的方法為post。呼叫Service方法返回登入處理結果,響應json資料。
@RequestMapping(value="/user/login",method=RequestMethod.POST)
@ResponseBody
public ResultObject login(String username,String password,HttpServletRequest request,HttpServletResponse response){
try {
ResultObject result = loginService.login(username, password, request, response);
return result;
} catch (Exception e) {
e.printStackTrace();
return ResultObject.build(500, "登入失敗");
}
}
Service接收使用者名稱、密碼。校驗密碼是否正確,生成token,向redis中寫入使用者資訊,把token寫入cookie,並在返回結果中包含token。
public ResultObject login(String username, String password,
HttpServletRequest request, HttpServletResponse response) {
//校驗使用者名稱密碼是否正確
TbUserExample example = new TbUserExample();
Criteria criteria = example.createCriteria();
criteria.andUsernameEqualTo(username);
List<TbUser> list=userMapper.selectByExample(example);
//取使用者資訊
if(list==null||list.isEmpty()){
return ResultObject.build(400, "使用者名稱或密碼錯誤");
}
TbUser user=list.get(0);
//校驗密碼
if(!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes()))){
return ResultObject.build(400, "使用者名稱或密碼錯誤");
}
//登入成功,生成token
String token = UUID.randomUUID().toString();
//把使用者資訊寫入redis
//key:REDIS_SESSION:{TOKEN}
//value:user轉成json
user.setPassword(null);
jedisClient.set(REDIS_SESSION_KEY+":"+token, JsonUtils.objectToJson(user));
//設定session過期時間
jedisClient.expire(REDIS_SESSION_KEY+":"+token, SESSION_EXPIRE);
//寫cookie
CookieUtils.setCookie(request, response, "PSP_TOKEN", token);
return ResultObject.ok(token);
}
2)通過token查詢使用者資訊
Controller從url中取token的內容,呼叫Service取使用者資訊,響應json資料。
@RequestMapping("/user/token/{token}")
@ResponseBody
public Object getUserByToken(@PathVariable String token,String callback){
try {
ResultObject result = loginService.getUserByToken(token);
if(StringUtils.isNotBlank(callback)){
System.out.println("callback!!");
MappingJacksonValue mappingJacksonValue=new MappingJacksonValue(result);
System.out.println(mappingJacksonValue.toString());
return mappingJacksonValue;
}
return result;
} catch (Exception e) {
e.printStackTrace();
return ResultObject.build(500, "獲取使用者資訊失敗");
}
}
Service接收token,根據token查詢redis,查詢到結果返回使用者物件,更新過期時間。如果查詢不到結果,返回Session已經過期,狀態碼400。
public ResultObject getUserByToken(String token) {
//根據token取使用者資訊
String json = jedisClient.get(REDIS_SESSION_KEY+":"+token);
//判斷是否查詢到結果
if(StringUtils.isBlank(json)){
return ResultObject.build(400, "使用者session已過期");
}
//把json轉換成java物件
TbUser user = JsonUtils.jsonToPojo(json, TbUser.class);
//更新session過期時間
jedisClient.expire(REDIS_SESSION_KEY+":"+token, SESSION_EXPIRE);
return ResultObject.ok(user);
}
2.展示註冊和登入頁面
在SSO系統中只有登入註冊功能,所以只需要兩個頁面就可以了,下面是其跳轉Controller
@Controller
public class PageController {
/**
* 展示登入頁面
*/
@RequestMapping("/page/login")
public String showLogin(String redirectURL,Model model){
//需要把引數傳遞到jsp,頁面回撥
model.addAttribute("redirect", redirectURL);
return "login";
}
/**
* 展示註冊頁面
*/
@RequestMapping("/page/register")
public String showRegister(){
return "register";
}
}
我們這裡為了解決登入回撥,在登入頁面的js實現如下:
var redirectUrl = "${redirect}";
var LOGIN = {
checkInput:function() {
if ($("#loginname").val() == "") {
alert("使用者名稱不能為空");
$("#loginname").focus();
return false;
}
if ($("#nloginpwd").val() == "") {
alert("密碼不能為空");
$("#nloginpwd").focus();
return false;
}
return true;
},
doLogin:function() {
$.post("/user/login", $("#formlogin").serialize(),function(data){
if (data.status == 200) {
alert("登入成功!");
if (redirectUrl == "") {
location.href = "http://www.psp.com";
} else {
location.href = redirectUrl;
}
} else {
alert("登入失敗,原因是:" + data.msg);
$("#loginname").select();
}
});
},
login:function() {
if (this.checkInput()) {
this.doLogin();
}
}
};
$(function(){
$("#loginsubmit").click(function(){
LOGIN.login();
});
});
3、其他系統整合SSO
現在我們就來演示其他系統對SSO進行整合,這裡就以門戶系統整合SSO為例。
1.門戶登入
當用戶在首頁點選登入或者註冊的時候需要跳轉到sso系統。進行相應的操作。登入成功跳轉到首頁。首頁應該顯示當前登入的使用者。首先門戶系統的登入按鈕程式碼如下,
<a href="http://sso.psp.com/user/page/login">登入</a>
只是一個簡單的超連結,跳轉到SSO登入頁面,並進行相關的登入操作,當登入完成後,在首頁展示使用者。其前端實現如下,
checkLogin : function(){
var _ticket = $.cookie("PSP_TOKEN");
if(!_ticket){
return ;
}
$.ajax({
url : "http://sso.psp.com/user/token/" + _ticket,
dataType : "json",
type : "GET",
success : function(data){
if(data.status == 200){
var username = data.data.username;
var html = username + ",您好!";
$("#loginbar").html(html);
}
}
});
}
2.登入攔截器
在Poratl系統中,對於有些頁面是需要登入之後才能訪問的,比如訂單頁面,當用戶檢視訂單頁面時此時必須要求使用者登入,可以使用攔截器來實現。攔截器的處理流程為:
- 攔截請求url
- 從cookie中取token
- 如果沒有toke跳轉到登入頁面。
- 取到token,需要呼叫sso系統的服務查詢使用者資訊。
- 如果使用者session已經過期,跳轉到登入頁面
- 如果沒有過期,放行。
其中攔截器配置如下,攔截order下的所有操作
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/order/**"/>
<bean class="com.psp.portal.interceptor.LoginInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
然後在springmvc中需要實現HandlerInterceptor介面。
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Value("${SSO_LOGIN_URL}")
private String SSO_LOGIN_URL;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object object) throws Exception {
// 1、攔截請求url
// 2、從cookie中取token
// 3、如果沒有toke跳轉到登入頁面。
// 4、取到token,需要呼叫sso系統的服務查詢使用者資訊。
TbUser user = userService.getUserByToken(request, response);
// 5、如果使用者session已經過期,跳轉到登入頁面
if (user == null) {
response.sendRedirect(SSO_LOGIN_URL+"?redirectURL="+request.getRequestURI());
return false;
}
//把使用者物件放入request中
request.setAttribute("user", user);
// 6、如果沒有過期,放行。
return true;
}
@Override
public void afterCompletion(HttpServletRequest arg0,
HttpServletResponse response, Object object, Exception exception)
throws Exception {
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object object, ModelAndView modelAndView) throws Exception {
}
}
其對應的Service作用為,根據token取使用者資訊,如果取到返回TbUser物件,如果取不到,返回null。
@Service
public class UserServiceImpl implements UserService {
@Value("${SSO_BASE_URL}")
private String SSO_BASE_URL;
@Value("${SSO_USER_TOKEN_SERVICE}")
private String SSO_USER_TOKEN_SERVICE;
@Override
public TbUser getUserByToken(HttpServletRequest request,
HttpServletResponse response) {
try {
//從cookie中獲取token
String token = CookieUtils.getCookieValue(request, "PSP_TOKEN");
//判斷token是否有值
if(StringUtils.isBlank(token)){
return null;
}
//呼叫sso的查詢服務
String json = HttpClientUtil.doGet(SSO_BASE_URL+SSO_USER_TOKEN_SERVICE+token);
//把json轉換成java物件
ResultObject result = ResultObject.format(json);
if(result.getStatus()!=200){
return null;
}
//取使用者物件
result = ResultObject.formatToPojo(json, TbUser.class);
TbUser user=(TbUser) result.getData();
return user;
} catch (Exception e) {
return null;
}
}
}