SSO 概念

单点登录(Single Sign On),简称为 SSO;
SSO核心意义就一句话:一处登录,处处登录;一处注销,处处注销。
即用户只需要记住一组用户名和密码就可以登录所有有权限的系统。

使用场景

单点登录在大型网站里使用得非常之多,阿里旗下淘宝、天猫、支付宝、阿里巴巴,阿里妈妈,阿里妹妹等网站,以及这些网站背后成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协作,每一个系统都要登录一次,用户与开发人员岂不是要疯掉。
所以,单点登录要解决的就是,用户只需要登录一次就可以访问所有相互信任的应用系统。

单点登录过程

如下图所示:
image.png

各个工作节点

用户(手机、浏览器、其他…)
系统(APP-1、APP-2、APP-3…)
认证中心(sso)
用户信息(数据库、redis、缓存、cas…)

单点登录过程

  1. 当用户第一次访问应用APP-1的时候,因为还没有登录,会被引导到认证系统(SSO)的登录页面进行登录;
  2. 用户在登录页面提供登录信息,一般是用户名、密码,认证系统进行身份效验,如果效验通过,登录成功,同时应该返回给用户一个认证的凭据-ticket;
  3. 用户再访问别的应用APP-2、APP-3的时候,利用cookie、session或者其他方式,将会带上这个ticket,作为自己认证的凭据,应用系统接受到请求之后会把ticket送到认证系统进行效验,检查ticket的合法性。如果通过效验,用户就可以在不用再次登录的情况下访问应用APP-2、APP-3了。
  4. 使用完系统后,需要退出登录,假设用户在APP-1注销登录,那么APP-1不能再访问,APP-2、APP-3、…也跟着不能再访问,通俗理解就是APP-1、APP-2、APP-3…任意系统全都要注销登录。

SSO 实现方式

  1. 基于Cookie的单点登录
    最简单的单点登录实现方式,是使用cookie作为媒介,存放用户凭证。 用户登录父应用之后,应用返回一个加密的cookie,当用户访问子应用的时候,携带上这个cookie,授权应用解密cookie并进行校验,校验通过则登录当前用户
    1. Cookie不安全
    2. 不能跨域实现免登
  2. 分布式session单点登录
    用户第一次登录时,将会话信息(用户Id和用户信息),比如以用户Id为Key,写入分布式Session;
    用户再次登录时,获取分布式Session,是否有会话信息,如果没有则跳转到登录页;
    一般采用Cache中间件实现,比如Redis,redis有持久化功能,方便分布式Session宕机后,可以从持久化存储中加载会话信息。
  3. spring-security + oauth2
  4. shiro + cas
  5. jwt

    session方式举例,代码实现

    注:我这里用session方式举例,cookie等其他方式方式可自行研究

  6. 选用任意一种浏览器,用于用户做登录操作、请求APP资源

  7. 客户端-sso-client:搭建一个客户端-sso-client,这个client只处理登录逻辑,再搭建多个APP,这些APP分别引入这个client

注:以下我在说明APP-的时候,统一用sso-client-

sso-client-1 sso-client-2 sso-client-3
端口:8081 端口:8082 端口:8083
页面:index.html 页面:home.html 页面:work.html
各种资源接口 各种资源接口 各种资源接口
  1. 还要搭建一个认证中心-sso-server | sso-server | | —- | | 端口:8080 | | 页面:login.html | | 各种接口 |

  2. 这个sso-server主要做以下这几个事情

    1. 登录
    2. 保存登录信息
    3. 校验ticket
    4. 注销登录
  3. 存储用户信息,咱们就简单点,直接在sso-server用静态的map、list等结构存储,当然也可以换成其他方式,比如:数据库、Redis…

客户端-sso-client-1、客户端-sso-client-2、客户端-sso-client-3

  1. 客户端-sso-client,这里用spring拦截器处理,且假设拦截所的有访问,如下代码
  1. @Configuration
  2. public class LoginConfig implements WebMvcConfigurer{
  3. @Override
  4. public void addInterceptors(InterceptorRegistry registry) {
  5. //注册TestInterceptor拦截器
  6. InterceptorRegistration registration = registry.addInterceptor(new SsoHander());
  7. //所有请求都被拦截
  8. registration.addPathPatterns("/**");
  9. }
  10. }
  1. 既然sso-client要处理登录逻辑,具体的登录、校验、保存用户信息等操作是在认证中心(sso-server),所以咱们得让sso-client知道sso-server地址,所以需要在sso-client写一个sso-server地址配置,为了方便,我就在这里写死吧。
    1. SSO_SERVER: sso-server地址
    2. SSO_SERVER_VERIFY: sso-server校验ticket地址
    3. SSO_SERVER_CHECK_LOGIN: sso-server检验是否登录的地址
/**
 * SsoHander
 * 处理登录逻辑
 */
public class SsoHander implements HandlerInterceptor {
    /** sso-server地址*/
    private static final String SSO_SERVER = "http://localhost:8080/sso";
    private static final String SSO_SERVER_VERIFY = "/verify";
    private static final String SSO_CHECK_LOGIN = "/checkLogin";

    /** session 中的登录标志*/
    private static final String IS_LOGIN = "isLogin";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        HttpSession session = req.getSession();

        //是否已经存在回话
        Boolean isLogin = (Boolean) session.getAttribute(IS_LOGIN);
        if (isLogin != null && isLogin) {
            return true;
        }

        // 校验ticket
        String ticket = req.getParameter("ticket");
        if (StringUtils.isEmpty(ticket)) {
            ssoSendRedirect(req, resp);
            return true;
        } else {
            // 到sso-verver校验ticket
            if (verify(req, session, ticket)) {
                return true;
            } else {
                ssoSendRedirect(req, resp);
            }
        }
        return false;
    }

    private void ssoSendRedirect (HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String service = ServerUtil.getCurAddr(req) + req.getServletPath();
        resp.sendRedirect(SSO_SERVER + SSO_SERVER_CHECK_LOGIN + "?service=" + service);
    }

    private boolean verify (HttpServletRequest req, HttpSession session, String token) {
        //去sso校验ticket
        Map<String, Object> paramMap = new HashMap<>();
        paramMap.put("token", token);
        paramMap.put("service", ServerUtil.getCurAddr(req) + "/logout");
        paramMap.put("jsessionid", session.getId());
        String str = HttpUtil.httpPost(SSO_SERVER + SSO_SERVER_VERIFY, paramMap);
        if (str != null && "true".equals(str)) {
            session.setAttribute(IS_LOGIN, true);
            return true;
        }
        return false;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {

    }
}
  1. 我现在通过浏览器访问http://localhost:8081/sso-client-1/index.html,嘿嘿,要过拦截器,也就进入了方法preHandle(***) 方法
    1. 首先就得判断sso-client-1是否已经有了session 回话,哎呀,第一次进来,过不了呀
    2. 那就得再判断一下ticket,这个时候,哪有什么ticket,还是直接通过ssoSendRedirect(*)重定向到sso-server的单点登录页面吧
    3. 不过为了让sso-server知道现在是sso-client-1重定向到sso-server的单点登录页面,且登录后再回到sso-client-1,得主动给个标记:service=http://localhost:8081/sso-client-1/index.html

image.png

  1. 用户通过用户名、密码登录后,重定向到:http://1localhost:8081/sso-client-1/index.html?ticket=503fc221486c4c09b1aaf573bfdecea3,拦截器再次拦截,ssso-client-1没有session,但是这个时候会携带sso-server返回的ticket,所以这是时候会通过verify(***)检验ticket,并且校验通过会在sso-client-1设置session,放行该请求

image.png

  1. 咱们再访问sso-client-1的其他资源,比如//http://localhost:8081/sso-client-1/world.html,再次通过拦截器,这是时候sso-client-1已经有session直接放行该请求

image.png

  1. 我再次访问sso-client-2,这是一个新系统,我们访问资源:http://localhost:8081/sso-client-2/home.html,拦截器拦截,这时没有session和ticket,那就直接去sso-verver校验,由于sso-server的session 已经有了,所以校验通过,也就再次重定向,http://localhost:8081/sso-client-1/home.html,拦截器拦截,嘿嘿,这个时候已经有了ticket,老规矩,verify(***)检验ticket,并且校验通过会在sso-client-1设置session,放行该请求

image.png

  1. 咱们再访问 sso-client-2的其他资源,这就 “同5”一样了

    sso-server

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>登录</title>
    <meta http-equiv="Access-Control-Allow-Origin" content="*">
    <script type="text/javascript" src="js/lib/jquery-3.4.1.min.js"></script>
    <script type="text/javascript" src="js/login.js"></script>
</head> 
<body>
    <div>
        <form id="fmId" action="/sso/login" method="post">
            <input id="serviceId" name="service" type="text" th:value="${service}">
            <input id="usernameShowId" name="username" type="text">
            <input id="passwordShowId" name="password" type="password">
            <input type="submit" value="登录">
        </form>
    </div>
</body>
</html>
  1. sso-server 检查登录,用户向客户端sso-client-发请求时,sso-client-没有session也没有ticket,那就通过这个方法验证
    1. sso-server 没有全局session回话,跳转到登录页面:http://localhost:8080/sso/login?service=http://localhost:8081/sso-client-1/index.html
      ,这个时候要带上sso-client-*传递过来了service,不能丢
    2. 有全局session回话,重定向到sso-client-*传递过来了service
/**
 * 
 * UserLoginController
 * @description 检查登录,用户向客户端发请求时,没有session也没有ticket,那就通过这个方法验证
 * @param service 用户向客户端发送的请求
 * @param session session
 * @return 重定向的地址,1、登录页面;2、客户端的请求地址
 * @throws UnsupportedEncodingException
 * @author fengzhiqiang
 * @date 2020年8月23日 下午11:12:32
 * @version v1.0.0
 */
@RequestMapping("/checkLogin")
    public String checkLogin(
            @RequestParam(name = "service", required = false) String service,
            HttpSession session) throws UnsupportedEncodingException {
        String token = (String) session.getAttribute("token");
        if (StringUtils.isEmpty(token)) {
            // 没有全局回话,跳转到登录页面
            return "redirect:index?service=" + service;
        } else {
            // 存在全局回话,返回调用的地方
            return "redirect:" + service + "?ticket=" + token;
        }
    }
  1. 跳转sso-server登录页面,sso-server需要知道是什么叫地方跳转到登录页面,也就是登录端的地址,也就是下面代码中的service参数,比如:http://localhost:8080/sso/index?service=http://localhost:8081/sso-client-1/index.html
    1. 检验用户名(admin)、密码(123456)
    2. LoginConstant.DB_TOKEN 是存储token(ticket)
    3. 成功跳转到service,也就是http://localhost:8081/sso-client-1/index.html
/**
 * 
 * LoginController
 * @description 重定向到login.html
 * @param service 客户端
 * @param model
 * @return static/login.html
 * @author fengzhiqiang
 * @date 2020年3月7日 上午12:49:27
 * @version 0.0.1
 * @throws UnsupportedEncodingException 
 */
@GetMapping("/index")
public String login(@RequestParam(name = "service", required = false) String service, Model model) {
    model.addAttribute("service", service);
    return "login";
}
/**
 * 登录
 * LoginController
 * @description 登录
 * @param username 用户名
 * @param password 密码
 * @param 地址
 * @param req Request
 * @return 登录成功
 * @author fengzhiqiang
 * @date 2020年3月7日 上午12:50:31
 * @version 0.0.1
 * @throws IOException 
 */
@RequestMapping(value = "/login")
public void login(
        @RequestParam(name = "username", required = false) String username, 
        @RequestParam(name = "password", required = false) String password,
        @RequestParam(name = "service", required = false) String service,
        HttpServletRequest req, 
        HttpServletResponse resp,
        HttpSession session) throws IOException {
    if ("admin".equals(username) && "123456".equals(password)) {
        //校验成功,创建token信息  
        String token = UUIDHelper.getUUID32();
        session.setAttribute("token", token);
        // 模拟存储token
        LoginConstant.DB_TOKEN.add(token);
        //登录成功,令牌返回到客户端
        WebUtils.issueRedirect(req, resp, SsoUtil.getRedirectURL(service, token));
        return;
    }
    //回到登录页面
    WebUtils.issueRedirect(req, resp, "login?service=" + service);
}
  1. sso-server校验ticket,也就是用户向客户端发送请求时,客户端发现没有session,但是这个请求带了ticket,那就需要在sso-server验证该tickt
    1. 也就是查看LoginConstant.DB_TOKEN是否存在该ticket或者token
    2. 这里要多做一个处理,得把客户端信息保存起来也就是ClientInfo,包含客户端的url信息,jsessionid信息,这是因为一会儿咱们退出登录时候,需要知道具体的sso-client—*和session
/**
 * 
 * UserLoginController
 * @description 校验token是否由统一认证中心产生的
 * @param token ticket或token
 * @return
 * @author fengzhiqiang
 * @date 2020年4月16日 上午1:15:36
 * @version TODO
 */
@RequestMapping("/verify")
@ResponseBody
public String verifyToken(
        @RequestParam(name = "token", required = false) String token, 
        @RequestParam(name = "service", required = false) String service, 
        @RequestParam(name = "jsessionid", required = false) String jsessionid){
    if (LoginConstant.DB_TOKEN.contains(token)){
        //模拟通过tocket获取客户端信息
         List<ClientInfo> cs = LoginConstant.DB_CLIENT.get(token);
         if (cs == null) {
             cs = new ArrayList<>();
             LoginConstant.DB_CLIENT.put(token, cs);
         }
         ClientInfo ci = new ClientInfo();
         ci.setClientUrl(service);
         ci.setJsessionid(jsessionid);

         cs.add(ci);

        return "true";
    }
    return "false";
}
  1. sso-server退出登录,下面logout方法只是注销了sso-server里面的session,客户端client的session也需要注销,由于可能存在很多的客户端client,那需要一一发送请求去注销,那就sso-serer中通过监听器监听session的注销,需要注意的是,我们在sso-server存储各个客户端信息的时候,是保存了客户端url和jsessionid,通过url和jsessionid,才知道注销的是哪个客户端以及该客户端的哪个session
    1. 咱们要注销sso-service的session
    2. 监听sso-service的session注销
      1. LoginConstant.DB_TOKEN 删除ticket 或者token,
      2. 然后向各个sso-client—* 发送请求session注销请求,至于发送请求的方法sendHttpRequest()自行实现吧
    3. 退出了还是要记住是哪个客户端退出的,service=http://localhost:8081/index.html

image.png

@RequestMapping("/logout")
public String logout (@RequestParam(name = "service", required = false) String service,
        HttpSession session) {
    //注销session
    session.invalidate();
    //登录页面
    return "redirect:index?service=" + service;
}
@WebListener
public class SessionListener implements HttpSessionListener{

    @Override
    public void sessionCreated(HttpSessionEvent se) {

    }

    /**
     * 监听session销毁,然后各个客户端session都销毁 
     * @see javax.servlet.http.HttpSessionListener#sessionDestroyed(javax.servlet.http.HttpSessionEvent)
     */
    @Override
    public void sessionDestroyed(HttpSessionEvent se) {
        HttpSession session = se.getSession();


        String token = (String) session.getAttribute("token");
        LoginConstant.DB_TOKEN.remove(token);

        List<ClientInfo> cs = LoginConstant.DB_CLIENT.remove(token);
        try {
            if (CollectionUtils.isNotEmpty(cs)) {
                for (ClientInfo clientInfo : cs) {
                    //发送注销登录请求
                    Map<String, Object> paramMap = new HashMap<>();
                    paramMap.put("jsessionid", clientInfo.getJsessionid());
                    HttpUtil.sendHttpRequest(clientInfo.getClientUrl(), (String) paramMap.get("jsessionid"));
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}