长文慎入!大厂架构演进实战之手写 CAS 单点登录

时间:2022-07-25
本文章向大家介绍长文慎入!大厂架构演进实战之手写 CAS 单点登录,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

什么是单点登录

单点登录在大型网站里使用得非常频繁,那么什么是单点登录?一句话解释:一处登录,处处登录。

比如,淘宝和天猫都属于阿里旗下,账号也是通用的,一个账号即可以登录淘宝,又可以登录天猫,这样也是为了方便用户,如果每个子系统都需要单独注册账号的话就太麻烦了,所以是统一账户可以登录同属于一家公司的所有子系统。

那么这些子系统每次都需要登录吗?肯定不是的,比如淘宝登录之后,你在访问天猫就不需要登录,系统会自动识别完成登录的,这就是单点登录。所以单点登录要解决的就是,用户只需要登录一次就可以访问所有相互信任的应用系统。

单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO 并不能算是一种架构,只能说是一个解决方案。SSO 核心意义就一句话:一处登录,处处登录;一处注销,处处注销。

就是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统,即用户只需要记住一组用户名和密码就可以登录所有的子系统。

举个现实生活中的例子:旅游年票大家都应该知道,就是买一张票,所有景点都可以去,旅游年票就是单点登录的思想。大家想想如果你没有这张年票,假如你来西安旅游,你今天去大雁塔需要买票,明天去钟楼还需要买票,后天去兵马俑还得买一次票,也就是说每去一个景点都得买一张票。

你参观西安的每个旅游景点都需要买一次票,是不是就相当于你访问一个大型应用的每个子系统都需要进行登录?这样就比较麻烦,怎么解决呢?你买一张西安的旅游年票不就好了么,有了这张年票,你去哪个任何一个景点都是直接进,不需要再买票了。

这个年票是不是就相当于,你只需要登录一次,之后在一个大型应用的所有子系统中都可以自动登录,不需要再输入账户密码了,这就是单点登录的思想。

单点登录实现原理

我们用淘宝和天猫为例给大家讲解单点登录的实现原理,大致如下。

1、当用户第一次访问应用淘宝的时候,因为还没有登录,会被引导到认证系统中进行登录。

2、根据用户提供的登录信息, 认证系统进行身份效验,如果通过效验, 则登录成功,并返回给用户一个认证的凭据 token。

3、当用户访问天猫时, 就会将这个 token 带上,作为自己认证的凭据。

4、应用系统接受到请求之后会把 token 送到认证系统进行校验,检查 token 的合法性。

5、如果通过校验,用户就可以在不用再次登录的情况下访问天猫了。

如果你还不太懂,楠哥给你画了张图,一看就懂的那种。

单点登录解决方案

SSO 是一种思想,具体的解决方案有很多,目前业内比较常用的有以下 3 种:

1、OAuth2

主要用来做第三方登录授权,第三方系统访问主系统资源,用户无需将主系统的账号告知第三方,只需通过主系统的授权,第三方就可使用主系统的资源。

最常见的就是 APP 需使用微信支付,当你在京东买东西使用微信支付时,会自动提示用户是否授权,用户授权后,京东就可使用微信支付功能了,京东和微信是有合作关系的。

所以 OAuth2 是用来允许用户授权的第三方应用,访问用户已经登录过的另外一个服务器上的资源,的一种协议,它不是用来做单点登录的,但我们可以利用它来实现单点登录。

2、JWT

Json web token (JWT),是为了在网络应用之间传递信,息而执行的一种基于 JSON 的开放标准,难度较大,需要了解很多协议,所以它是一种偏向底层的东西,需要你基于 JWT 认证协议,自己开发 SSO 服务和权限控制。

3、CAS

Central Authentication Service(中央认证服务),CAS 是耶鲁大学发起的一个开源项目,为 Web 应用系统提供的单点登录解决方案,实现多个系统只需登录一次,无需重复登录,支持 Java、PHP、.NET 等语言。

CAS 包含两个部分:CAS Server 和 CAS Client

CAS Server 和 CAS Client 分别独立部署,CAS Server 主要负责认证工作。

CAS Client 负责处理对客户端资源的访问请求,需要登录时,重定向到 CAS Server 进行认证。

我们以 CAS 为例,自己写一套代码的实现,以此来彻底搞清楚 SSO,下面开始撸代码啦。

手写 CAS

正式开撸之前,我们先来分析 CAS 流程。

1、授权服务器保存一个全局 session,多个客户端各自保存自己的 session。

2、客户端登录时判断自己的 session 是否已登录,若未登录,则(告诉浏览器)重定向到授权服务器(参数带上自己的地址,用于回调)。

3、授权服务器判断全局的 session 是否已登录,若未登录则定向到登录页面,提示用户登录,登录成功后,授权服务器重定向到客户端(参数带上 token)。

4、客户端收到 Token 后,请求服务器获取用户信息。

5、服务器同意客户端授权后,服务端保存用户信息至全局 session,客户端将用户保存至本地 session。

代码实现

1、创建客户端 ssoclienttaobao ,完成 Controller 跳转 index.html 的逻辑。‍

@Controller
public class TaobaoController {

    @GetMapping("/taobao")
    public String index(){
        return "index";
    }
}

2、添加拦截器 TaobaoInterceptor。

public class TaobaoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断会话是否存在
        HttpSession session = request.getSession();
        Boolean isLogin = (Boolean) session.getAttribute("isLogin");
        if(isLogin!=null && isLogin){
            return true;
        }
        //判断token
        String token = request.getParameter("token");
        if(!StringUtils.isEmpty(token)){
            
        }
        //token为空,登录认证
        SSOClientUtil.redirectToCheckToken(request, response);
        return false;
    }
}

3、创建配置类,使拦截器生效。

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        String[] addPathPattrens = {"/taobao"};
        registry.addInterceptor(new TaobaoInterceptor()).addPathPatterns(addPathPattrens);
    }
}

4、创建认证中心 ssoserver,实现 checkToken 方法。‍

@Controller
public class SSOServerController {

    @GetMapping("/checkToken")
    public String checkToken(String redirectUrl, HttpServletRequest request,HttpSession session, Model model){
        String token = (String) session.getServletContext().getAttribute("token");
        if(StringUtils.isEmpty(token)){
            model.addAttribute("redirectUrl", redirectUrl);
            return "login";
        }else{
            return "";
        }
    }
}

5、实现 login 方法,登录成功,重新回到 ssoclienttaobao 客户端。

@PostMapping("/login")
public String login(String username,String password,String redirectUrl,HttpSession session,Model model){
    if("admin".equals(username) && "123123".equals(password)){
        String token = UUID.randomUUID().toString();
        session.getServletContext().setAttribute("token", token);
        MockDB.tokenSet.add(token);
        return "redirect:"+redirectUrl+"?token="+token;
    }else{
        return "login";
    }
}

1、再次进入 ssoclienttaobao 客户端拦截器,此时有 token,进行验证。

//判断token
String token = request.getParameter("token");
if(!StringUtils.isEmpty(token)){
    //token存在,进行验证
    String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
    HashMap<String,String> params = new HashMap<>();
    params.put("token", token);
    String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
    if("true".equals(isVerify)){
        //验证通过
        Cookie cookie = new Cookie("token", token);
        response.addCookie(cookie);
        session.setAttribute("isLogin", true);
        return true;
    }
}

7、实现 ssoserver 的 verify 方法。

@PostMapping("/verify")
@ResponseBody
public String verifyToken(String token){
    if(MockDB.tokenSet.contains(token)){
        return "true";
    }
    return "false";
}

8、ssoclienttaobao 登录逻辑完成,创建另外一个客户端 ssoclienttmall,完成 Controller 跳转 index.html 的逻辑。

@Controller
public class TmallController {

    @GetMapping("/tmall")
    public String index(){
        return "index";
    }
}

9、创建拦截器 TmallInterceptor。

public class TmallInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();
        Boolean isLogin = (Boolean) session.getAttribute("isLogin");
        if(isLogin != null && isLogin){
            return true;
        }
        String token = request.getParameter("token");
        if(!StringUtils.isEmpty(token)){
            String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
            HashMap<String,String> params = new HashMap<>();
            params.put("token", token);
            String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
            if("true".equals(isVerify)){
                Cookie cookie = new Cookie("token", token);
                response.addCookie(cookie);
                session.setAttribute("isLogin", true);
                return true;
            }
        }
        SSOClientUtil.redirectToCheckToken(request, response);
        return false;
    }
}

10、创建拦截器配置类 InterceptorConfiguration。

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        String[] addPathPattrens = {"/tmall"};
        registry.addInterceptor(new TmallInterceptor()).addPathPatterns(addPathPattrens);
    }
}

11、我们的逻辑是先让 taobao 登录,然后 tmall 登录的时候 ssoserver 直接检测全局 token 即可(taobao 登录成功会存入全局 token),所以接下来完善 ssoserver checkToken 的方法。

@GetMapping("/checkToken")
public String checkToken(String redirectUrl, HttpServletRequest request, HttpSession session, Model model){
    String token = (String) session.getServletContext().getAttribute("token");
    if(StringUtils.isEmpty(token)){
        model.addAttribute("redirectUrl", redirectUrl);
        return "login";
    }else{
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if(cookie.getValue().equals(token)){
                return "redirect:"+redirectUrl+"?token="+token;
            }
        }
        model.addAttribute("redirectUrl", redirectUrl);
        return "login";
    }
}

到目前为止,单点登录的逻辑全部实现了,楠哥再给大家梳理一下思路。

(1)taobao 首先访问,被拦截,检测到没有 token,进入 ssoserver 的 checktoken 方法,此时全局 token 为空,进入登录页面,完成登录逻辑,生成 token 存入全局 token,并且将 token 存入数据库,再带着这个 token 返回 taobao。

(2)进入 taobao 拦截器,有 token,进行验证,进入 ssoserver 的 verify 方法,从数据库中查询,token 存在,则返回 true。

(3)回到 taobao 拦截器,结果为 true,将 token 存入 Cookie(给 tmall 检测使用),并将 isLogin = true 存入本地 session,返回 true,通过拦截器,进入页面,taobao 登录逻辑完成。

(4)tmall 访问,被拦截,检测到没有 token,进入 ssoserver 的 checktoken 方法,此时全局 token 存在,则对比 Cookie,如果 Cookie 中没有相等的 token,则登录,如果有相等的 token,则表示其他子项目(taobao)已登录过,tmall 不需要再次登录,带着这个 token 返回 tmall。

(5)进入 tmall 拦截器,有 token,进行验证,进入 ssoserver 的 verify 方法,从数据库中查询,token 存在,则返回 true。

(6)回到 tmall 拦截器,结果为 true,将 token 存入 Cookie,并将 isLogin = true 存入本地 session,返回 true,通过拦截器,进入页面,tmall 登录逻辑完成。

实现了单点登录,接下来我们实现单点登出。

思路很简单:ssoserver 销毁 session,并且触发监听器,删除全局会话中的 token,删除数据库中的 token,通知所有客户端销毁 session,删除数据库中客户端登出 URL 集合。

1、taobao 和 tmall 两个客户端进入首页的方法需要将 ssoserver 的 logoutUrl 存入 model,给页面退出使用。

@Controller
public class TaobaoController {

    @GetMapping("/taobao")
    public String index(Model model){
        model.addAttribute("serverLogoutUrl", SSOClientUtil.getServerLogoutUrl());
        return "index";
    }
}
@Controller
public class TmallController {

    @GetMapping("/tmall")
    public String index(Model model){
        model.addAttribute("serverLogoutUrl", SSOClientUtil.getServerLogoutUrl());
        return "index";
    }
}

2、实现 ssoserver 的 logout 方法。

@GetMapping("/logout")
public String logout(HttpSession session){
    session.invalidate();
    return "login";
}

3、创建监听器,监听 session 销毁的行为,分别让 taobao 和 tmall 客户端执行登出逻辑。

@WebListener
public class SessionListener implements HttpSessionListener {
    @Override
    public void sessionDestroyed(HttpSessionEvent se) {
        String token = (String) se.getSession().getServletContext().getAttribute("token");
        se.getSession().getServletContext().removeAttribute("token");
        MockDB.tokenSet.remove(token);
        Set<String> set = MockDB.clientLogoutUrlMap.get(token);
        Iterator<String> iterator = set.iterator();
        while (iterator.hasNext()){
            HttpUtil.sendHttpRequest(iterator.next(), null);
        }
        MockDB.clientLogoutUrlMap.remove(token);
    }
}

4、创建监听器配置类,让监听器生效。

@Configuration
public class ListenerConfiguration {

    @Bean
    public ServletListenerRegistrationBean listenerRegistrationBean(){
        ServletListenerRegistrationBean servletListenerRegistrationBean = new ServletListenerRegistrationBean();
        servletListenerRegistrationBean.setListener(new SessionListener());
        return servletListenerRegistrationBean;
    }
}

5、基本逻辑实现完成,现在还差一步,给 MockDB 存入客户端的登出 URL,这一步在 token 验证过程中完成。

@PostMapping("/verify")
@ResponseBody
public String verifyToken(String token,String clientLogoutUrl){
    if(MockDB.tokenSet.contains(token)){
        Set<String> set = MockDB.clientLogoutUrlMap.get(token);
        if(set == null){
            set = new HashSet<>();
            MockDB.clientLogoutUrlMap.put(token, set);
        }
        set.add(clientLogoutUrl);
        return "true";
    }
    return "false";
}

6、客户端在验证的时候需要将各自的登出 URL 传给 ssoserver。

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    HttpSession session = request.getSession();
    Boolean isLogin = (Boolean) session.getAttribute("isLogin");
    if(isLogin != null && isLogin){
        return true;
    }
    String token = request.getParameter("token");
    if(!StringUtils.isEmpty(token)){
        String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
        HashMap<String,String> params = new HashMap<>();
        params.put("token", token);
        params.put("clientLogoutUrl", SSOClientUtil.getClientLogoutUrl());
        String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
        if("true".equals(isVerify)){
            Cookie cookie = new Cookie("token", token);
            response.addCookie(cookie);
            session.setAttribute("isLogin", true);
            return true;
        }
    }
    SSOClientUtil.redirectToCheckToken(request, response);
    return false;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    HttpSession session = request.getSession();
    Boolean isLogin = (Boolean) session.getAttribute("isLogin");
    if(isLogin != null && isLogin){
        return true;
    }
    String token = request.getParameter("token");
    if(!StringUtils.isEmpty(token)){
        String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
        HashMap<String,String> params = new HashMap<>();
        params.put("token", token);
        params.put("clientLogoutUrl", SSOClientUtil.getClientLogoutUrl());
        String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
        if("true".equals(isVerify)){
            Cookie cookie = new Cookie("token", token);
            response.addCookie(cookie);
            session.setAttribute("isLogin", true);
            return true;
        }
    }
    SSOClientUtil.redirectToCheckToken(request, response);
    return false;
}

7、taobao、tmall 各自的 Controller 添加 logout 方法。

@PostMapping("/logout")
public String logout(HttpSession session){
    session.invalidate();
    return "redirect:/taobao";
}
@PostMapping("/logout")
public String logout(HttpSession session){
    session.invalidate();
    return "redirect:/tmall";
}

代码全部完成,以上就是手写 CAS 单点登录的整个过程,能看到这的小伙伴请一定要跟着楠哥的思路自己手撸一遍,彻底搞清楚单点登录的原理,源码链接

https://github.com/southwind9801/sso