Java架构直通车——Redis+Cookie实现单点登录SSO

引入

单点登录又称之为Single Sign On,简称SSO,单点登录可以通过基于用户会话的共享,他分文两种,先来看第一种,那就是他的原理是分布式会话来实现。

比如说现在有个一级域名为 www.imooc.com,是教育类网站,但是慕课网有其他的产品线,可以通过构建二级域名提供服务给用户访问,比如:music.imooc.com,shop.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 CASController {
    @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);

        //完善校验是否已经登陆
        //获取用户门票userticket,如果已经登陆过,签发临时票据
        if (verifyUserTicket(request)) {
            String tmpTicket = createTmpTicket();
            return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
        }


        return "login";
    }

    /**
     * CAS统一接口的目的:
     * 登陆后创建用户的全局会话  -> userToken
     * 创建用户的全局门票,用以表示用户是否在CAS端已登陆  -> userTicket
     * 创建用户的临时门票,用以回跳和回传  -> tmpTicket
     */
    @PostMapping("/doLogin")
    public String doLogin(String returnUrl, String username, String password,
                          Model model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        model.addAttribute("returnUrl", returnUrl);

        //1.登陆
        if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
            model.addAttribute("errmsg", "用户名或密码不能为空");
            return "login";
        }
        Users userResult = userService.queryUserForLogin(username, MD5Utils.getMD5Str(password));

        if (userResult == null) {
            model.addAttribute("errmsg", "用户名或密码不正确");
            return "login";
        }

        //2.实现用户会话
        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. 生成用户全局门票
        String userTicket = UUID.randomUUID().toString().trim();
        //3.1 用户全局门票需要放入cookie中
        setCookie(COOKIE_USER_TICKET, userTicket, response);
        //4. userTicket需要关联userId,并且放入到redis中
        redisOperator.set(REDIS_USER_TICKET + ":" + userTicket, userResult.getId());

        //5. 生成临时票据,回跳到调用端网站,是由CAS签发的临时ticket
        String tmpTicket = createTmpTicket();
        /**
         * userTicket用于表示用户在CAS端登陆的状态:是否已经登陆
         * tmpTicket用于给用户颁发登陆的票据,是一次性的。这个凭证可以获取用户登陆态的信息
         */

        //用户从未登陆过,则跳转到cas的统一登陆页面

        return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;

//        return "login";
    }

    /**
     * 使用一次性临时票据来检测用户是否登陆过,使用完毕后需要销毁临时票据
     *
     * @param tmpTicket
     * @param request
     * @param response
     * @return
     */
    @PostMapping("/verifyTmpTicket")
    @ResponseBody
    public IMOOCJSONResult verifyTmpTicket(@RequestParam("tmpTicket") String tmpTicket,
                                           HttpServletRequest request, HttpServletResponse response) throws Exception {
        String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" + tmpTicket);
        if (StringUtils.isBlank(tmpTicketValue)) {
            return IMOOCJSONResult.errorMsg("用户票据异常");
        }
        //0.如果临时票据ok,那么需要销毁,并且拿到CAS端的全局ticket,以此再获取用户会话
        if (!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))) {
            return IMOOCJSONResult.errorMsg("用户票据异常");
        } else {
            //临时票据校验成功,销毁它
            redisOperator.del(REDIS_TMP_TICKET + ":" + tmpTicket);
        }

        //1. 验证,并且换取userTicket
        String userTicket = getCookie(COOKIE_USER_TICKET, request);
        if (StringUtils.isBlank(userTicket)) {
            return IMOOCJSONResult.errorMsg("用户票据异常");
        }
        String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
        if (StringUtils.isBlank(userId)) {
            return IMOOCJSONResult.errorMsg("用户票据异常");
        }

        //2.验证userId对应的会话是否存在
        String userSession = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(userSession)) {
            return IMOOCJSONResult.errorMsg("用户票据异常");
        }
        //3.验证成功,返回用户会话
        return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userSession, UsersVO.class));
    }

    @PostMapping("/logout")
    @ResponseBody
    public IMOOCJSONResult logout(String userId,
                                  HttpServletRequest request,
                                  HttpServletResponse response) throws Exception {

        // 0. 获取CAS中的用户门票
        String userTicket = getCookie(COOKIE_USER_TICKET, request);

        // 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();
    }

    //创建临时票据
    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 value, HttpServletResponse response) {
        Cookie cookie = new Cookie(key, value);
        cookie.setDomain("cas.com");
        cookie.setPath("/");
        response.addCookie(cookie);
    }

    private void deleteCookie(String key, HttpServletResponse response) {
        Cookie cookie = new Cookie(key, null);
        cookie.setDomain("cas.com");
        cookie.setPath("/");
        cookie.setMaxAge(-1);
        response.addCookie(cookie);

    }

    private String getCookie(String key, HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null || StringUtils.isBlank(key)) {
            return null;
        }
        String cookieValue = null;
        for (Cookie c : cookies) {
            if (c.getName().equals(key)) {
                cookieValue = c.getValue();
                break;
            }
        }
        return cookieValue;

    }

    private boolean verifyUserTicket(HttpServletRequest request) {
        String userTicket = getCookie(COOKIE_USER_TICKET, request);
        if (StringUtils.isBlank(userTicket)) {
            return false;
        }
        String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
        if (StringUtils.isBlank(userId)) {
            return false;
        }
        String userSession = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
        if (StringUtils.isBlank(userSession)) {
            return false;
        }
        return true;
    }


}

是否跨域的区别

如果还有点云里雾里,可以参考SSO说明,这篇文章给出了很详细的时序图。

是否需要跨域,这点关键就是Cookie的保存位置的问题。

  • 不跨域的登陆,后端可以直接调用response来保存设置cookie,并且只需要设置相同的根domain就可以了。

  • 跨域的登陆,目前我看到是有两种方案。
    在这里插入图片描述

    1. 第一种方案是将Cookie保存在SSO域名下,这种方式我们可以推想一下:
      每次相关网站需要验证是否登陆的时候,需要先跳转到sso域名下(因为cookie是由浏览器发送的),然后再由浏览器发送带Cookie的请求到后端验证登陆。验证成功后、或者登陆成功后,先回到sso域名下,保存/更新cookie,然后再跳转回登陆成功的相应的returnUrl页面。
    2. 第二种方式是将Cookie保存在所有域名下,也就是调用一个接口来轮询设置所有域名的cookie。
      每次相关网站需要验证是否登陆的时候,先检查自己有没有cookie,没有则需要先跳转到sso域名下进行登陆。我们考虑有cookie的情况,就去请求sso接口来验证cookie,然后跳转到returnUrl。如果没有cookie,则进行登陆,然后设置各个域名的cookie。
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值