背景
单点登录又称之为Single Sign On,简称SSO,单点登录可以通过基于用户会话的共享,他分文两种,先来看第一种,那就是他的原理是分布式会话来实现。
比如说现在有个一级域名为 www.imooc.com ,是教育类网站,但是慕课网有其他的产品线,可以通过构建二级域名提供服务给用户访问,比如: music.imooc.com , blog.imooc.com 等等,分别为慕课音乐以及慕课博客等,用户只需要在其中一个站点登录,那么其他站点也会随之而登录。
也就是说,用户自始至终只在某一个网站下登录后,那么他所产生的会话,就共享给了其他的网站,实现了单点网站登录后,同时间接登录了其他的网站,那么这个其实就是单点登录,他们的会话是共享的,都是同一个用户会话。
Cookie + Redis 实现 SSO
那么之前我们所实现的分布式会话后端是基于redis的,如此会话可以流窜在后端的任意系统,都能获取到缓存中的用户数据信息,前端通过使用cookie,可以保证在同域名的一级二级下获取,那么这样一来,cookie中的信息userid和token是可以在发送请求的时候携带上的,这样从前端请求后端后是可以获取拿到的,这样一来,其实用户在某一端登录注册以后,其实cookie和redis中都会带有用户信息,只要用户不退出,那么就能在任意一个站点实现登录了。
那么这个原理主要也是cookie和网站的依赖关系,顶级域名 www.imooc.com 和 *.imooc.com的cookie值是可以共享的,可以被携带至后端的,比如设置为 .imooc.com,.t.mukewang.com,如此是OK的。
二级域名自己的独立cookie是不能共享的,不能被其他二级域名获取,比如:music.imooc.com的cookie是不能被mtv.imooc.com共享,两者互不影响,要共享必须设置为.imooc.com。
顶级域名不同怎么办?
上一节单点登录是基于相同顶级域名做的,那么如果顶级域名都不一样,咋办?比如 www.imooc.com 要和www.mukewang.com 的会话实现共享,这个时候又该如何?!如下图,这个时候的cookie由于顶级域名不同,就不能实现cookie跨域了,每个站点各自请求到服务端,cookie无法同步。比如,www.imooc.com 下的用户发起请求后会有cookie,但是他又访问了www.abc.com ,由于cookie无法携带,所以会要你二次登录。
那么遇到顶级域名不同却又要实现单点登录该如何实现呢?我们来参考下面一张图:
如上图所示,多个系统之间的登录会通过一个独立的登录系统去做验证,它就相当于是一个中介公司,整合了所有人,你要看房经过中介允许拿钥匙就行,实现了统一的登录。那么这个就称之为CAS系统,CAS全称为Central Authentication Service即中央认证服务,是一个单点登录的解决方案,可以用于不同顶级域名之间的单点登录。
过程解析
用户首次登录时流程如下:
1)、用户浏览器访问系统A需登录受限资源,此时进行登录检查,发现未登录,然后进行获取票据操作,发现没有票据。
2)、系统A发现该请求需要登录,将请求重定向到认证中心,获取全局票据操作,没有,进行登录。
3)、认证中心呈现登录页面,用户登录,登录成功后,认证中心重定向请求到系统A,并附上认证通过令牌,此时认证中心同时生成了全局票据。
4)、此时再次进行登录检查,发现未登录,然后再次获取票据操作,此时可以获得票据(令牌),系统A与认证中心通信,验证令牌有效,证明用户已登录。
5)、系统A将受限资源返给用户
已登录用户首次访问应用群中系统B时:
1)、浏览器访问另一应用B需登录受限资源,此时进行登录检查,发现未登录,然后进行获取票据操作,发现没有票据。
2)、系统B发现该请求需要登录,将请求重定向到认证中心,获取全局票据操作,获取全局票据,可以获得,认证中心发现已经登录。
3)、认证中心发放临时票据(令牌),并携带该令牌重定向到系统B。
4)、此时再次进行登录检查,发现未登录,然后再次获取票据操作,此时可以获得票据(令牌),系统B与认证中心通信,验证令牌有效,证明用户已登录。
5)、系统B将受限资源返回给客户端。
全局票据的意义就是判断用户是否已经在认证中心登陆过。
临时票据的意义是签发给用户一个登陆的认证。
代码
@Controller public class SSOController { @Autowired private UserService userService; @Autowired private RedisOperator redisOperator; public static final String REDIS_USER_TOKEN = "redis_user_token"; public static final String REDIS_USER_TICKET = "redis_user_ticket"; public static final String REDIS_TMP_TICKET = "redis_tmp_ticket"; public static final String COOKIE_USER_TICKET = "cookie_user_ticket"; @GetMapping("/login") public String login(String returnUrl, Model model, HttpServletRequest request, HttpServletResponse response) { model.addAttribute("returnUrl", returnUrl); // 1. 获取userTicket门票,如果cookie中能够获取到,证明用户登录过,此时签发一个一次性的临时票据并且回跳 String userTicket = getCookie(request, COOKIE_USER_TICKET); boolean isVerified = verifyUserTicket(userTicket); if (isVerified) { String tmpTicket = createTmpTicket(); return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket; } // 2. 用户从未登录过,第一次进入则跳转到CAS的统一登录页面 return "login"; } /** * 校验CAS全局用户门票 * @param userTicket * @return */ private boolean verifyUserTicket(String userTicket) { // 0. 验证CAS门票不能为空 if (StringUtils.isBlank(userTicket)) { return false; } // 1. 验证CAS门票是否有效 String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket); if (StringUtils.isBlank(userId)) { return false; } // 2. 验证门票对应的user会话是否存在 String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId); if (StringUtils.isBlank(userRedis)) { return false; } return true; } /** * CAS的统一登录接口 * 目的: * 1. 登录后创建用户的全局会话 -> uniqueToken * 2. 创建用户全局门票,用以表示在CAS端是否登录 -> userTicket * 3. 创建用户的临时票据,用于回跳回传 -> tmpTicket */ @PostMapping("/doLogin") public String doLogin(String username, String password, String returnUrl, Model model, HttpServletRequest request, HttpServletResponse response) throws Exception { model.addAttribute("returnUrl", returnUrl); // 0. 判断用户名和密码必须不为空 if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) { model.addAttribute("errmsg", "用户名或密码不能为空"); return "login"; } // 1. 实现登录 Users userResult = userService.queryUserForLogin(username, MD5Utils.getMD5Str(password)); if (userResult == null) { model.addAttribute("errmsg", "用户名或密码不正确"); return "login"; } // 2. 实现用户的redis会话 String uniqueToken = UUID.randomUUID().toString().trim(); UsersVO usersVO = new UsersVO(); BeanUtils.copyProperties(userResult, usersVO); usersVO.setUserUniqueToken(uniqueToken); redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(), JsonUtils.objectToJson(usersVO)); // 3. 生成ticket门票,全局门票,代表用户在CAS端登录过 String userTicket = UUID.randomUUID().toString().trim(); // 3.1 用户全局门票需要放入CAS端的cookie中 setCookie(COOKIE_USER_TICKET, userTicket, response); // 4. userTicket关联用户id,并且放入到redis中,代表这个用户有门票了,可以在各个景区游玩 redisOperator.set(REDIS_USER_TICKET + ":" + userTicket, userResult.getId()); // 5. 生成临时票据,回跳到调用端网站,是由CAS端所签发的一个一次性的临时ticket String tmpTicket = createTmpTicket(); /** * userTicket: 用于表示用户在CAS端的一个登录状态:已经登录 * tmpTicket: 用于颁发给用户进行一次性的验证的票据,有时效性 */ /** * 举例: * 我们去动物园玩耍,大门口买了一张统一的门票,这个就是CAS系统的全局门票和用户全局会话。 * 动物园里有一些小的景点,需要凭你的门票去领取一次性的票据,有了这张票据以后就能去一些小的景点游玩了。 * 这样的一个个的小景点其实就是我们这里所对应的一个个的站点。 * 当我们使用完毕这张临时票据以后,就需要销毁。 */ // return "login"; return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket; } @PostMapping("/verifyTmpTicket") @ResponseBody public IMOOCJSONResult verifyTmpTicket(String tmpTicket, HttpServletRequest request, HttpServletResponse response) throws Exception { // 使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点 // 使用完毕后,需要销毁临时票据 String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" + tmpTicket); if (StringUtils.isBlank(tmpTicketValue)) { return IMOOCJSONResult.errorUserTicket("用户票据异常"); } // 0. 如果临时票据OK,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获取用户会话 if (!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))) { return IMOOCJSONResult.errorUserTicket("用户票据异常"); } else { // 销毁临时票据 redisOperator.del(REDIS_TMP_TICKET + ":" + tmpTicket); } // 1. 验证并且获取用户的userTicket String userTicket = getCookie(request, COOKIE_USER_TICKET); String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket); if (StringUtils.isBlank(userId)) { return IMOOCJSONResult.errorUserTicket("用户票据异常"); } // 2. 验证门票对应的user会话是否存在 String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId); if (StringUtils.isBlank(userRedis)) { return IMOOCJSONResult.errorUserTicket("用户票据异常"); } // 验证成功,返回OK,携带用户会话 return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userRedis, UsersVO.class)); } @PostMapping("/logout") @ResponseBody public IMOOCJSONResult logout(String userId, HttpServletRequest request, HttpServletResponse response) throws Exception { // 0. 获取CAS中的用户门票 String userTicket = getCookie(request, COOKIE_USER_TICKET); // 1. 清除userTicket票据,redis/cookie deleteCookie(COOKIE_USER_TICKET, response); redisOperator.del(REDIS_USER_TICKET + ":" + userTicket); // 2. 清除用户全局会话(分布式会话) redisOperator.del(REDIS_USER_TOKEN + ":" + userId); return IMOOCJSONResult.ok(); } /** * 创建临时票据 * @return */ private String createTmpTicket() { String tmpTicket = UUID.randomUUID().toString().trim(); try { redisOperator.set(REDIS_TMP_TICKET + ":" + tmpTicket, MD5Utils.getMD5Str(tmpTicket), 600); } catch (Exception e) { e.printStackTrace(); } return tmpTicket; } private void setCookie(String key, String val, HttpServletResponse response) { Cookie cookie = new Cookie(key, val); cookie.setDomain("sso.com"); cookie.setPath("/"); response.addCookie(cookie); } private void deleteCookie(String key, HttpServletResponse response) { Cookie cookie = new Cookie(key, null); cookie.setDomain("sso.com"); cookie.setPath("/"); cookie.setMaxAge(-1); response.addCookie(cookie); } private String getCookie(HttpServletRequest request, String key) { Cookie[] cookieList = request.getCookies(); if (cookieList == null || StringUtils.isBlank(key)) { return null; } String cookieValue = null; for (int i = 0 ; i < cookieList.length; i ++) { if (cookieList[i].getName().equals(key)) { cookieValue = cookieList[i].getValue(); break; } } return cookieValue; } } 复制代码
参考链接: