記一次手寫單點登入
系統流程設計
學習單點登入首推:
極客分布式系統單點登陸入門到基礎到原理實戰
以下是我的學習心得

關于Cookie資訊跨域共享的實作:
- 利用HTML Script标簽跨域寫Cookie
- P3P協定
- 通過URL參數實作跨域資訊的傳遞
單點登入的核心問題就是如何解決Cookie跨域的讀寫,這裡我們采用的是通過URL參數實作跨域資訊的傳遞。
記關閉浏覽器為一次會話,浏覽器關閉後vt失效
SSO伺服器登入入口:
/**
* 登入入口
*
* @param request
* @param backUrl
* @param response
* @param map
* @return
* @throws Exception
*/
@RequestMapping(value = "/login", method = RequestMethod.GET)
public String login(HttpServletRequest request, String backUrl,
HttpServletResponse response, ModelMap map, Boolean notLogin) throws Exception {
String vt = CookieUtil.getCookie("VT", request);
if (vt == null) { // VT不存在
String lt = CookieUtil.getCookie("LT", request);
if (lt == null) { // VT不存在,LT也不存在
return authFailed(notLogin, response, backUrl);
} else { // VT不存在, LT存在
LoginUser loginUser =config.getAuthenticationHandler().autoLogin(lt);
if (loginUser == null) {
return authFailed(notLogin, response, backUrl);
} else {
vt = authSuccess(response, loginUser, true);
return validateSuccess
(backUrl, vt, loginUser, response,map);
}
}
} else { // 跳轉到登入頁
LoginUser loginUser = TokenManager.validate(vt);
if (loginUser != null) { // VT有效
return validateSuccess(backUrl, vt, loginUser, response, map);
// 驗證成功後操作
} else { // VT 失效,轉入登入頁
// return config.getLoginViewName();
return authFailed(notLogin, response, backUrl);
}
}
}
VT不存在,LT也不存在,并且允許授權登入 跳轉到登入頁,不允許則跳轉403
VT不存在,LT存在,并且驗證使用者通過,無需輸入登入資訊;未通過,跳轉到登入頁,未授權跳轉403。
VT存在且有效,無需輸入登入資訊;無效跳轉到登入頁,未授權跳轉403。
Vt存在但失效,跳轉到登入頁,未授權跳轉403
登入校驗:
@RequestMapping(method = RequestMethod.POST, value = "/login")
public String login(String backUrl, Boolean rememberMe,
HttpServletRequest request, HttpSession session,
HttpServletResponse response, ModelMap map) throws Exception {
final Map<String, String[]> params = request.getParameterMap();
//"login_session_attr_name"
final Object sessionVal = session.
getAttribute(IPreLoginHandler.SESSION_ATTR_NAME);
Credential credential = new Credential() {
@Override
public String getParameter(String name) {
String[] tmp = params.get(name);
return tmp != null && tmp.length > 0 ? tmp[0] : null;
}
@Override
public String[] getParameterValue(String name) {
return params.get(name);
}
@Override
public Object getSettedSessionValue() {
return sessionVal;
}
};
LoginUser loginUser = config.getAuthenticationHandler()
.authenticate(credential);
if (loginUser == null) {
map.put("errorMsg", credential.getError());
return config.getLoginViewName();
} else {
String vt = authSuccess(response, loginUser, rememberMe);
return validateSuccess(backUrl, vt, loginUser, response, map);
}
}
Credential是對登入頁面送出的内容集中存儲,并提供特定擷取方法的一個實體類
登入校驗成功後在服務端域中生成vt,根據是否選擇自動登入來生成lt,伺服器緩存中存取vt,持久化lt在本地。設定請求頭P3P,将cookie存入浏覽器中。
用戶端核心代碼:
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)resp;
//如果該請求允許通過,就向下執行
if (this.requestIsExclude(request)) {
chain.doFilter(request, response);
} else {
this.logger.debug("進入SSOFilter,目前請求url: {}",request.getRequestURL());
//在目前浏覽器中獲得key值為VT的cookie
String vt = CookieUtil.getCookie("VT", request);
//如果vt不為空,即->使用者在登入成功一次後
if (vt != null) {
SSOUser user = null;
try {
//遠端連接配接伺服器查詢user,或者從本地緩存查詢(優先在本地查詢)
//private static final Map<String, TokenManager.Token> LOCAL_CACHE = new HashMap();
user = TokenManager.validate(vt);
} catch (Exception var9) {
throw new ServletException(var9);
}
if (user != null) {
// private static final ThreadLocal<SSOUser>
//userThreadLocal = new ThreadLocal();
//request.setAttribute("__current_sso_user", user);
// userThreadLocal.set(user);存入本地緩存
this.holdUser(user, request);
//向下執行
chain.doFilter(request, response);
} else {
//vt失效删除
CookieUtil.deleteCookie("VT", response, "/");
//轉發入伺服器登入頁面驗證登入
this.loginCheck(request, response);
}
} else {
String vtParam = this.pasreVtParam(request);
if (vtParam == null) {
//"__vt_param__"根本沒出現
this.loginCheck(request, response);
} else if (vtParam.length() == 0) {
//"__vt_param__="
response.sendError(403);
} else {
this.redirectToSelf(vtParam, request, response);
}
}
}
}
使用者第一次在業務系統1發送請求時,會進入用戶端的SSOFilter,此時業務系統1并不存在vt,本地存放token的緩存中也為空,并且還未進行自動登入選擇(rememberMe),也意味着并沒在本地緩存中持久化user,此時浏覽器會重定向到服務端執行登入校驗,由于服務端也并不存在vt和lt,會再次跳轉到登入頁。當使用者輸入正确的使用者名和密碼發送post請求後,sso伺服器會生成一個vt,并在請求中攜帶vt的參數重定向到業務系統1發送的請求,再次進入SSOFilter,然後再次重定向,将vt寫入業務系統1的cookie中,再次進入SSOFilter,驗證使用者成功後,請求繼續向下進行。
在完成該操作之後,使用者第一次在業務系統2發送請求,同理浏覽器會重定向到服務端執行登入校驗,但是如果此時伺服器中存在vt和lt ,無需登入驗證,請求繼續向下進行。不存在lt仍需要登入校驗
用戶端遠端校驗:
// 遠端驗證vt有效性
private static SSOUser remoteValidate(String vt) throws Exception {
URL url = new URL(serverIndderAddress + "/validate_service?vt=" + vt);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
InputStream is = conn.getInputStream();
conn.connect();
byte[] buff = new byte[is.available()];
is.read(buff);
String ret = new String(buff, "utf-8");
conn.disconnect();
is.close();
UserDeserializer userDeserializer = UserDeserailizerFactory.create();
SSOUser user =
StringUtil.isEmpty(ret) ? null : userDeserializer.deserail(ret);
if (user != null) {
// 處理本地緩存
cacheUser(vt, user);
}
return user;
}
伺服器接收:
/**
* 提供系統内網間VT驗證服務
*/
@WebServlet("/validate_service")
public class ValidateServiceServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws ServletException,
IOException {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
// 用戶端傳來的vt
String vt = request.getParameter("vt");
LoginUser user = null;
// 驗證vt有效性
if (vt != null) {
user = TokenManager.validate(vt);
}
// 傳回結果
Config config = SpringContextUtil.getBean(Config.class);
UserSerializer userSerializer = config.getUserSerializer();
try {
response.getWriter().write(userSerializer.serial(user));
} catch (Exception e) {
throw new ServletException(e);
}
}
}
定時任務實時更新vt過期時間,若vt過期則删除:
private static final Timer timer = new Timer(true);
static {
timer.schedule(new TimerTask() {
@Override
public void run() {
for (Entry<String, Token> entry : DATA_MAP.entrySet()) {
String vt = entry.getKey();
Token token = entry.getValue();
Date expired = token.expired;
Date now = new Date();
// 目前時間大于過期時間
if (now.compareTo(expired) > 0) {
// 因為令牌支援自動延期服務,并且應用用戶端緩存機制後,
// 令牌最後通路時間是存儲在用戶端的,
//是以服務端向所有用戶端發起一次timeout通知,
// 用戶端根據lastAccessTime + tokenTimeout計算是否過期,<br>
// 若未過期,用各用戶端最大有效期更新目前過期時間
List<ClientSystem> clientSystems = config
.getClientSystems();
Date maxClientExpired = expired;
for (ClientSystem clientSystem : clientSystems) {
Date clientExpired = clientSystem.noticeTimeout(vt,
config.getTokenTimeout());
if (clientExpired != null
&& clientExpired.compareTo(now) > 0) {
maxClientExpired =
maxClientExpired.compareTo(clientExpired) < 0 ?
clientExpired : maxClientExpired;
}
}
if (maxClientExpired.compareTo(now) > 0) {
// 用戶端最大過期時間大于目前
logger.debug("更新過期時間到" + maxClientExpired);
token.expired = maxClientExpired;
} else {
logger.debug("清除過期token:" + vt);
// 已過期,清除對應token
DATA_MAP.remove(vt);
}
}
}
}
}, 60 * 1000, 60 * 1000);
}
使用Timer開啟定時任務
如何在用戶端定時的删除無效的vt?
用戶端token對象中存儲的是使用者最後登陸時間和使用者資訊,服務端token對象中存儲的是vt過期時間和使用者資訊。利用定時任務工具,伺服器定時和用戶端進行通信,發送noticeTimeout通知,把vt有效時間段傳給用戶端,用戶端接收到該參數後進行比較,若過期傳null,不過期把目前用戶端過期時間傳給服務端。若用戶端清單中接受的參數都為空,則删除該vt。若至少一個不為null,sso伺服器通過判斷将用戶端清單中的最大過期時間設為服務端的vt過期時間,更新vt。
服務端發送noticeTimeout:
/**
* 與用戶端系統通信,通知用戶端token過期
*
* @param tokenTimeout
* @return 延期的有效期
* @throws MalformedURLException
*/
public Date noticeTimeout(String vt, int tokenTimeout) {
try {
String url = innerAddress +
"/notice/timeout?vt=" + vt + "&tokenTimeout=" + tokenTimeout;
String ret = httpAccess(url);
if (StringUtil.isEmpty(ret)) {
return null;
} else {
return new Date(Long.parseLong(ret));
}
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
private String httpAccess(String theUrl) throws Exception {
URL url = new URL(theUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(500);
InputStream is = conn.getInputStream();
conn.connect();
byte[] buff = new byte[is.available()];
is.read(buff);
String ret = new String(buff, "utf-8");
conn.disconnect();
is.close();
return ret;
}
用戶端接收:
/**
* 接收服務端發送的通知
*/
@WebServlet("/notice/*")
public class ServerNoticeServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// notice後路徑為notice類型,如/notice/timeout,則目前通知為timeout類型
String uri = request.getRequestURI();
String cmd = uri.substring(uri.lastIndexOf("/") + 1);
response.setContentType("text/plain");
response.setCharacterEncoding("utf-8");
switch (cmd) {
case "timeout": {
String vt = request.getParameter("vt");
int tokenTimeout = Integer.parseInt
(request.getParameter("tokenTimeout"));
Date expries = TokenManager.timeout(vt, tokenTimeout);
response.getWriter()
.write(expries == null ? "" : String.valueOf
(expries.getTime()));
break;
}
case "logout": {
String vt = request.getParameter("vt");
TokenManager.invalidate(vt);
response.getWriter().write("true");
break;
}
case "shutdown":
TokenManager.destroy();
response.getWriter().write("true");
break;
}
}
}
自動登入功能:
若在使用者登入界面選擇了自動登入,SSO伺服器中會生成lt令牌(設定過期時間),并持久化到本地。格式為lt=loginName,即使使用者關掉浏覽器,隻要在lt令牌失效的時間範圍内再次通路,無需再次登入。
lastLoginUserName功能:
<script type="text/javascript">
var UNAME_COOKIE_NAME = "lastLoginUserName";
$(function() {
// 如果name沒有value,将cookie中存儲過的name值寫入
var eleName = $("input[name=name]");
eleName.val(Cookie.get(UNAME_COOKIE_NAME));
// 登入按鈕被點選時記住目前name
$("form").submit(function() {
Cookie.set(UNAME_COOKIE_NAME, $.trim(eleName.val()), null, 7 * 24 * 60);
// 将密碼字段使用 MD5(MD5(密碼) + 驗證碼)編碼後發給服務端
var elePasswd = $("input[name=passwd]");
var passwd = elePasswd.val();
elePasswd.val($.md5($.md5(passwd) + $("input[name=captcha]").val()));
});
// 加載驗證碼
drawCaptcha();
});
function drawCaptcha() {
$.ajax("${appctx}/preLogin").done(function(data) {
console.log(data);
$("#captchaImg").attr("src", data.imgData);
}).fail(function() {
alert("驗證碼加載失敗");
});
}
</script>
每次登陸成功後都會更新該cookie,并且在登入頁面顯示使用者名