1. 单点登录
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
对于相同父域名下的单点登录比较简单,只需要将cookie的作用域放大到父域名即可。
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("ylogin.com");
cookieSerializer.setCookieName("YLOGINESSION");
return cookieSerializer;
}
本文主要分享一下不同应用服务器之间(即不同域名)的单点登录流程。
1. 单点登录流程
单点登录流程图如下
-
假设现在第一次访问Client1的受保护的资源,由于我们没有登录,则需要跳转到登录服务器进行登录,但是登录之后应该跳到哪里呢?很显然,需要跳回到我们想要访问的页面,所以在重定向到登录服务器时带上回调地址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 { // 由于域名不同,不能实现session共享,无法在登录页面展示msg session.setAttribute("msg","请先进行登录"); // 带上回调地址 return "redirect:http://auth.ylogin.com/login.html?redirectURL=http://ylogin.client1.com"+request.getServletPath(); } }
-
浏览器展示登录页
-
用户输入账号密码进行登录,并在隐藏域提交回调地址
-
登录服务器查询数据库,验证账号及密码。账号密码正确,则生成一个令牌sso_token,保存到cookie中(该cookie只存在于登录服务器),并将登录用户信息以sso_token为key,保存到redis中(剧透,顺便保存回调地址到redis)。然后携带上令牌重定向到回调地址(即登录前页面)。
@PostMapping("/login") public String login(UserLoginTo to, RedirectAttributes redirectAttributes, HttpServletResponse response) { //远程登陆 R login = userFeignService.login(to); if (login.getCode() == 0) { UserResponseVo data = login.getData(new TypeReference<UserResponseVo>() { }); log.info("登录成功!用户信息"+data.toString()); // 保存用户信息到redis(key->value:sso_token->登录用户信息) 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); // 携带令牌重定向到回调地址 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(); } }
-
应用服务器1拿到token,需要向验证服务器发起请求(也可以直接到redis中查是否存在这个key),验证是否存在该token。目的是为了防止伪造令牌。验证通过,则保存用户信息到本地session,(下次访问则无需经过登录服务器,判断session中存在用户即可),返回用户想到访问的含受保护资源页面。
@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)){ // 携带令牌,可能是已登录用户,需向登录服务器进行确认 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)){ // 验证通过,保存登录用户信息到本地session,下次访问则无需经过登录服务器 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","请先进行登录"); return "redirect:http://auth.ylogin.com/login.html?redirectURL=http://ylogin.client1.com"+request.getServletPath(); } }
-
用户再次发起请求,访问Client2中受保护的资源,同样会先到登录服务器的登录页面,但此时会带上cookie,登录服务器一看,有cookie,就知道这是一个在其他系统登录过的用户,就发放一个令牌,重定向到用户访问的地址。
@GetMapping("/login.html") public String loginPage(@RequestParam("redirectURL") String url, @CookieValue(value = "sso_token",required = false) String sso_token){ // 先判断是否在其他系统登录过 if (!StringUtils.isEmpty(sso_token)){ // 添加登录地址 addLoginUrl(url); System.out.println("已登录"); return "redirect:"+url+"?token="+sso_token; } return "login"; }
-
应用服务器2拿到令牌,同样需要到登录服务器进行验证,验证成功则保存用户信息到本地session,返回访问资源页面。
-
应用服务器判断用户是否登录,第一次看是否携带令牌,之后就看本地session中有没有登录用户的信息。
-
登录服务器判断用户是否登录,第一次就到数据库查询,之后就看是否携带cookie。
2. 单点登出流程
话不多说,先放个单点登出的流程图。
-
用户点击注销按钮,携带令牌到登录服务器进行验证,同样需要携带上回调地址(一般为公共资源页面即可),作为登出后展示在浏览器的页面。
你是不是有几个疑问呢。为什么退出登录也需要携带令牌?本地session中只保存了登录用户的基本信息,那要如何携带令牌到登录服务器呢?不着急,下面就为你解答。
-
携带令牌的目的是为了验证改退出请求是登录用户发起的,防止其他人恶意请求。
-
对于获取token,我们可以利用SessionID来获取token,所以我们必须在登录成功后,保存用户信息到session的同时,也保存SessionID和token的映射关系(可以使用静态map来保存)。
// SessionID->token private static final Map<String, String> sessionTokenMapping = new HashMap<>();
@GetMapping("/logout") public String logout(HttpServletRequest request){ // 根据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; }
-
-
登录服务器验证成功,向已经登陆的所有应用服务器发起注销请求(带上令牌)。所以我们需要知道有哪些应用服务器登陆了。这就是我在上面剧透的,登录服务器在验证登录时保存应用服务器地址。
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")){ // 验证令牌 if (cookie.getValue().equals(token)){ String value = cookie.getValue(); // 清除各应用系统的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中保存的用户信息 redisTemplate.delete(value); // 清除SSO服务器的cookie令牌 Cookie cookie1 = new Cookie("sso_token", ""); cookie1.setPath("/"); cookie1.setMaxAge(0); response.addCookie(cookie1); } } } } // 清除redis保存的登录url redisTemplate.delete("loginUrl"); return "redirect:"+url; }
-
应用服务器收到登录服务器的注销请求,首先验证令牌,判断是否是登录服务器发起的注销请求。
@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。登录服务器发送过来的请求,如果直接request.getSession().getId()获取,这样获取到的是新的session,并不是保存用户信息的会话。
-
为解决这一问题,在保存用户信息到本地session的同时,使用静态map来保存session,以令牌作为key。
// token->session private static final Map<String, HttpSession> localSession = new HashMap<>();
-
至此,单点登录功能基本实现。如果感兴趣,欢迎到我的github仓库获取源码。如果觉得有用的话,欢迎start。