天天看點

記一次手寫單點登入(SSO Single Sign On)

記一次手寫單點登入

系統流程設計

學習單點登入首推:

極客分布式系統單點登陸入門到基礎到原理實戰

以下是我的學習心得

記一次手寫單點登入(SSO Single Sign On)
關于Cookie資訊跨域共享的實作:
  1. 利用HTML Script标簽跨域寫Cookie
  2. P3P協定
  3. 通過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,并且在登入頁面顯示使用者名