1. 單點(diǎn)登錄
單點(diǎn)登錄(Single Sign On),簡(jiǎn)稱(chēng)為 SSO,是比較流行的企業(yè)業(yè)務(wù)整合的解決方案之一。SSO的定義是在多個(gè)應(yīng)用系統(tǒng)中,用戶(hù)只需要登錄一次就可以訪問(wèn)所有相互信任的應(yīng)用系統(tǒng)。
對(duì)于相同父域名下的單點(diǎn)登錄比較簡(jiǎn)單,只需要將cookie的作用域放大到父域名即可。
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("ylogin.com");
cookieSerializer.setCookieName("YLOGINESSION");
return cookieSerializer;
}
本文主要分享一下不同應(yīng)用服務(wù)器之間(即不同域名)的單點(diǎn)登錄流程。
1. 單點(diǎn)登錄流程
單點(diǎn)登錄流程圖如下

- 假設(shè)現(xiàn)在第一次訪問(wèn)Client1的受保護(hù)的資源,由于我們沒(méi)有登錄,則需要跳轉(zhuǎn)到登錄服務(wù)器進(jìn)行登錄,但是登錄之后應(yīng)該跳到哪里呢?很顯然,需要跳回到我們想要訪問(wèn)的頁(yè)面,所以在重定向到登錄服務(wù)器時(shí)帶上回調(diào)地址redirectURL。
@GetMapping("/abc")
public String abc(HttpServletRequest request,HttpSession session, @RequestParam(value = "token",required = false) String token) throws Exception {
if (!StringUtils.isEmpty(token)){
Map<String,String> map = new HashMap<>();
map.put("token",token);
HttpResponse response = HttpUtils.doGet("http://auth.ylogin.com", "/loginUserInfo", "GET", new HashMap<String, String>(), map);
String s = EntityUtils.toString(response.getEntity());
if (!StringUtils.isEmpty(s)){
UserResponseVo userResponseVo = JSON.parseObject(s, new TypeReference<UserResponseVo>() {
});
session.setAttribute(AuthServerConstant.LOGIN_USER,userResponseVo);
localSession.put(token,session);
sessionTokenMapping.put(session.getId(),token);
}
}
UserResponseVo attribute = (UserResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null){
return "abc";
} else {
// 由于域名不同,不能實(shí)現(xiàn)session共享,無(wú)法在登錄頁(yè)面展示msg
session.setAttribute("msg","請(qǐng)先進(jìn)行登錄");
// 帶上回調(diào)地址
return "redirect:http://auth.ylogin.com/login.html?redirectURL=http://ylogin.client1.com"+request.getServletPath();
}
}
瀏覽器展示登錄頁(yè)
用戶(hù)輸入賬號(hào)密碼進(jìn)行登錄,并在隱藏域提交回調(diào)地址
登錄服務(wù)器查詢(xún)數(shù)據(jù)庫(kù),驗(yàn)證賬號(hào)及密碼。賬號(hào)密碼正確,則生成一個(gè)令牌sso_token,保存到cookie中(該cookie只存在于登錄服務(wù)器),并將登錄用戶(hù)信息以sso_token為key,保存到redis中(劇透,順便保存回調(diào)地址到redis)。然后攜帶上令牌重定向到回調(diào)地址(即登錄前頁(yè)面)。
@PostMapping("/login")
public String login(UserLoginTo to, RedirectAttributes redirectAttributes, HttpServletResponse response) {
//遠(yuǎn)程登陸
R login = userFeignService.login(to);
if (login.getCode() == 0) {
UserResponseVo data = login.getData(new TypeReference<UserResponseVo>() {
});
log.info("登錄成功!用戶(hù)信息"+data.toString());
// 保存用戶(hù)信息到redis(key->value:sso_token->登錄用戶(hù)信息)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(token, JSON.toJSONString(data),2, TimeUnit.MINUTES);
// 添加登錄地址
addLoginUrl(to.getRedirectURL());
// 保存令牌到cookie
Cookie cookie = new Cookie("sso_token", token);
response.addCookie(cookie);
// 攜帶令牌重定向到回調(diào)地址
return "redirect:"+to.getRedirectURL()+"?token="+token;
} else {
Map<String, String> errors = new HashMap<>();
errors.put("msg", login.get("msg", new TypeReference<String>() {
}));
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.ylogin.com/login.html?redirectURL="+to.getRedirectURL();
}
}
- 應(yīng)用服務(wù)器1拿到token,需要向驗(yàn)證服務(wù)器發(fā)起請(qǐng)求(也可以直接到redis中查是否存在這個(gè)key),驗(yàn)證是否存在該token。目的是為了防止偽造令牌。驗(yàn)證通過(guò),則保存用戶(hù)信息到本地session,(下次訪問(wèn)則無(wú)需經(jīng)過(guò)登錄服務(wù)器,判斷session中存在用戶(hù)即可),返回用戶(hù)想到訪問(wèn)的含受保護(hù)資源頁(yè)面。
@ResponseBody
@GetMapping("/loginUserInfo")
public String loginUserInfo(@RequestParam("token") String token){
String s = redisTemplate.opsForValue().get(token);
return s;
}
@GetMapping("/abc")
public String abc(HttpServletRequest request,HttpSession session, @RequestParam(value = "token",required = false) String token) throws Exception {
// 判斷是否攜帶令牌
if (!StringUtils.isEmpty(token)){
// 攜帶令牌,可能是已登錄用戶(hù),需向登錄服務(wù)器進(jìn)行確認(rèn)
Map<String,String> map = new HashMap<>();
map.put("token",token);
HttpResponse response = HttpUtils.doGet("http://auth.ylogin.com", "/loginUserInfo", "GET", new HashMap<String, String>(), map);
String s = EntityUtils.toString(response.getEntity());
if (!StringUtils.isEmpty(s)){
// 驗(yàn)證通過(guò),保存登錄用戶(hù)信息到本地session,下次訪問(wèn)則無(wú)需經(jīng)過(guò)登錄服務(wù)器
UserResponseVo userResponseVo = JSON.parseObject(s, new TypeReference<UserResponseVo>() {
});
session.setAttribute(AuthServerConstant.LOGIN_USER,userResponseVo);
localSession.put(token,session);
sessionTokenMapping.put(session.getId(),token);
}
}
UserResponseVo attribute = (UserResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null){
return "abc";
} else {
session.setAttribute("msg","請(qǐng)先進(jìn)行登錄");
return "redirect:http://auth.ylogin.com/login.html?redirectURL=http://ylogin.client1.com"+request.getServletPath();
}
}
- 用戶(hù)再次發(fā)起請(qǐng)求,訪問(wèn)Client2中受保護(hù)的資源,同樣會(huì)先到登錄服務(wù)器的登錄頁(yè)面,但此時(shí)會(huì)帶上cookie,登錄服務(wù)器一看,有cookie,就知道這是一個(gè)在其他系統(tǒng)登錄過(guò)的用戶(hù),就發(fā)放一個(gè)令牌,重定向到用戶(hù)訪問(wèn)的地址。
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirectURL") String url, @CookieValue(value = "sso_token",required = false) String sso_token){
// 先判斷是否在其他系統(tǒng)登錄過(guò)
if (!StringUtils.isEmpty(sso_token)){
// 添加登錄地址
addLoginUrl(url);
System.out.println("已登錄");
return "redirect:"+url+"?token="+sso_token;
}
return "login";
}
應(yīng)用服務(wù)器2拿到令牌,同樣需要到登錄服務(wù)器進(jìn)行驗(yàn)證,驗(yàn)證成功則保存用戶(hù)信息到本地session,返回訪問(wèn)資源頁(yè)面。
應(yīng)用服務(wù)器判斷用戶(hù)是否登錄,第一次看是否攜帶令牌,之后就看本地session中有沒(méi)有登錄用戶(hù)的信息。
登錄服務(wù)器判斷用戶(hù)是否登錄,第一次就到數(shù)據(jù)庫(kù)查詢(xún),之后就看是否攜帶cookie。
2. 單點(diǎn)登出流程
話不多說(shuō),先放個(gè)單點(diǎn)登出的流程圖。

-
用戶(hù)點(diǎn)擊注銷(xiāo)按鈕,攜帶令牌到登錄服務(wù)器進(jìn)行驗(yàn)證,同樣需要攜帶上回調(diào)地址(一般為公共資源頁(yè)面即可),作為登出后展示在瀏覽器的頁(yè)面。
你是不是有幾個(gè)疑問(wèn)呢。為什么退出登錄也需要攜帶令牌?本地session中只保存了登錄用戶(hù)的基本信息,那要如何攜帶令牌到登錄服務(wù)器呢?不著急,下面就為你解答。
攜帶令牌的目的是為了驗(yàn)證改退出請(qǐng)求是登錄用戶(hù)發(fā)起的,防止其他人惡意請(qǐng)求。
對(duì)于獲取token,我們可以利用SessionID來(lái)獲取token,所以我們必須在登錄成功后,保存用戶(hù)信息到session的同時(shí),也保存SessionID和token的映射關(guān)系(可以使用靜態(tài)map來(lái)保存)。
// SessionID->token
private static final Map<String, String> sessionTokenMapping = new HashMap<>();
@GetMapping("/logout")
public String logout(HttpServletRequest request){
// 根據(jù)sessionId獲取token令牌
String sessionId = request.getSession().getId();
String token = sessionTokenMapping.get(sessionId);
return "redirect:http://auth.ylogin.com/logOut?redirectURL=http://ylogin.client1.com&token="+token;
}
- 登錄服務(wù)器驗(yàn)證成功,向已經(jīng)登陸的所有應(yīng)用服務(wù)器發(fā)起注銷(xiāo)請(qǐng)求(帶上令牌)。所以我們需要知道有哪些應(yīng)用服務(wù)器登陸了。這就是我在上面劇透的,登錄服務(wù)器在驗(yàn)證登錄時(shí)保存應(yīng)用服務(wù)器地址。
private void addLoginUrl(String url){
String s = redisTemplate.opsForValue().get("loginUrl");
if (StringUtils.isEmpty(s)){
List<String> urls = new ArrayList<>();
urls.add(url);
redisTemplate.opsForValue().set("loginUrl",JSON.toJSONString(urls));
} else{
List<String> urls = JSON.parseObject(s, new TypeReference<List<String>>() {
});
urls.add(url);
redisTemplate.opsForValue().set("loginUrl",JSON.toJSONString(urls));
}
}
@GetMapping("/logOut")
public String logout(HttpServletRequest request, HttpServletResponse response,@RequestParam("redirectURL") String url, @RequestParam("token") String token) throws Exception {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0){
for (Cookie cookie : cookies) {
if (cookie.getName().equals("sso_token")){
// 驗(yàn)證令牌
if (cookie.getValue().equals(token)){
String value = cookie.getValue();
// 清除各應(yīng)用系統(tǒng)的session
String s = redisTemplate.opsForValue().get("loginUrl");
Map<String, String> map = new HashMap<>();
map.put("token",value);
if (!StringUtils.isEmpty(s)){
List<String> urls = JSON.parseObject(s, new TypeReference<List<String>>() {
});
for (String loginUrl : urls) {
HttpUtils.doGet(loginUrl, "/deleteSession", "GET",new HashMap<String, String>(), map);
}
}
// 刪除redis中保存的用戶(hù)信息
redisTemplate.delete(value);
// 清除SSO服務(wù)器的cookie令牌
Cookie cookie1 = new Cookie("sso_token", "");
cookie1.setPath("/");
cookie1.setMaxAge(0);
response.addCookie(cookie1);
}
}
}
}
// 清除redis保存的登錄url
redisTemplate.delete("loginUrl");
return "redirect:"+url;
}
- 應(yīng)用服務(wù)器收到登錄服務(wù)器的注銷(xiāo)請(qǐng)求,首先驗(yàn)證令牌,判斷是否是登錄服務(wù)器發(fā)起的注銷(xiāo)請(qǐng)求。
@ResponseBody
@GetMapping("/abc/deleteSession")
public String logout(@RequestParam("token") String token){
HttpSession session = localSession.get(token);
// session.removeAttribute(AuthServerConstant.LOGIN_USER);
session.invalidate();
return "logout";
}
- 這里尤其需要注意,需要獲取指定session。登錄服務(wù)器發(fā)送過(guò)來(lái)的請(qǐng)求,如果直接request.getSession().getId()獲取,這樣獲取到的是新的session,并不是保存用戶(hù)信息的會(huì)話。
- 為解決這一問(wèn)題,在保存用戶(hù)信息到本地session的同時(shí),使用靜態(tài)map來(lái)保存session,以令牌作為key。
// token->session
private static final Map<String, HttpSession> localSession = new HashMap<>();
至此,單點(diǎn)登錄功能基本實(shí)現(xiàn)。如果感興趣,歡迎到我的github倉(cāng)庫(kù)獲取源碼。如果覺(jué)得有用的話,歡迎start。