单点登录原理及手写实现

1. 单点登录概念

单点登录在大型网站中使用较多,比如阿里旗下的淘宝,天猫,支付宝等,假如我登录天猫,在天猫上面买一件商品,然后要用支付宝进行支付,此时如果还要登录支付宝才能去支付的话,这样的用户体验是极差的,另外各个子系统也会因为这种重复认证的逻辑而疯掉。单点登录就是用户只需要登录一次就可以访问所有相互信任的应用系统,一句话概括就是:一处登录,处处登录,一处注销,处处注销

2. 单点登录演示

(1) 未登录淘宝和天猫时
在这里插入图片描述
(2)手机扫码登录淘宝,然后刷新天猫商城页面,可以看到,天猫商城也是登录状态了,此时退出天猫登录,然后刷新淘宝页面,发现淘宝也退出登录了。
在这里插入图片描述

3. 单点登录流程

3.1 服务器如何记录用户信息

由于Http协议是无状态的,用户第一次访问一个网站和第二次访问这个网站,对于服务端来说,它并不知道是同一个用户去访问的,用户的两次请求之间没有半毛钱关系。假设现在有一个单节点的网站,那么服务端怎么知道是否是同一个用户访问的呢?这就要靠会话机制了,浏览器第一次请求服务器,服务器创建一个会话Session,并将会话的id作为响应的一部分发送给浏览器,浏览器将会话id存储到Cookie中,并在后续请求中带上会话id,服务器取得请求中的会话id就知道是不是同一个用户了,这样后续请求就与第一次请求产生了关联。这样,单机时,服务器保存用户信息如下图所示:
在这里插入图片描述
简单来说,就是服务器端通过session保存用户信息 ,客户端浏览器cookie中保存session信息

不幸的是,目前的网站,单一节点服务器的场景很少了,在服务器集群的情况下,一般会通过nginx做负载均衡。
(1)在加入了nginx做负载均衡的情况下,假设这里nginx采用了轮询算法,如果用户第一次访问到webserver1,登录了,服务器记录一个session,但是第二次访问到webserver2,server2没有session,会要求用户登录,用户体验不好,而且两台服务器都需要保存session,如果nginx采用了iphash算法,nginx的负载均衡作用就失效了(iphash:只要用户第一次访问了server1,后面的请求都在server1上处理),并且容易导致一台服务器过载,万一这台服务器挂了,所有session都失效了
(2) 为了避免上面的问题,可以使用session共享:用户登录webserver1时,server1把用户的session共享到server2上面,但是session共享是有时间限制的,网络是不可靠的,假如有100台服务器,共享到50台的时候,50-100之间是没有session的,如果用户访问到这50-100之间的某台服务器,用户需要再次登录,因此session共享的方案,当集群数量过多时弊端也很大。
在这里插入图片描述
集群改进版:
在这里插入图片描述
基于session共享的问题,这个方案中把session存储到第三方,那么有个问题,存储在第三方的用户信息(token or ticket),用户又通过什么途径获取到呢?用户首次登录时,这个token在存到sso服务器上的时候也需要返回给客户端(保存在客户端的cookie里面) ——这一点很重要!用户再次访问时,携带token,webserver去查询该token是否存在,如果不存在,就认为用户是未登录状态,基于这种模型,如果登录淘宝后,把浏览器缓存清理了,刷新页面,淘宝应该会变成未登陆状态

3.2 单点登录

相比于单系统登录,sso需要一个独立的认证中心,所有子系统都通过认证中心的登录入口进行登录,登录时带上自己的地址,子系统只接受认证中心的授权,授权通过令牌(token)实现,sso认证中心验证用户的用户名密码正确,创建全局会话和token,token作为参数发送给各个子系统,子系统拿到token,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。
这个令牌就类似于去南京钟山风景区(明孝陵+灵谷寺+美龄宫+音乐台)买的联票,拿着这张联票,就不需要在各个景点再去买票了。单点登录整个过程如下:
在这里插入图片描述
注:图片来自于博客 单点登录原理与简单实现
这里以淘宝和天猫为例,单点登录流程简化如下:
在这里插入图片描述

4. 单点登录手写实现

4.1 创建项目

创建三个项目
这里创建一个项目及三个module: sso-client-tb, sso-client-tm, sso-server ,在父pom中添加如下依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.ssotest</groupId>
    <artifactId>sso-self</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <modules>
        <module>sso-server</module>
        <module>sso-client-tb</module>
        <module>sso-client-tm</module>
    </modules>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>5.2.1.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.10</version>
        </dependency>
        <dependency>
            <groupId>javax.servlet.jsp</groupId>
            <artifactId>jsp-api</artifactId>
            <version>2.2.1-b03</version>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>servlet-api</artifactId>
            <version>2.5</version>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>jstl</artifactId>
            <version>1.1.2</version>
        </dependency>

        <dependency>
            <groupId>taglibs</groupId>
            <artifactId>standard</artifactId>
            <version>1.1.2</version>
        </dependency>
    </dependencies>
</project>
4.2 SSO认证中心
package com.ssotest.controller;

import com.ssotest.db.MockDB;
import com.ssotest.pojo.ClientInfo;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Controller
public class SsoServerController {
    
    

    @RequestMapping("/index")
    public String index(){
    
    
        return "login";
    }

    /**
     *
     * @param username 登录账户
     * @param password 登陆密码
     * @param redirectUrl 登录时的页面地址,也就是登录成功后重定向url(登录页面要用隐藏域携带 )
     * @param session 服务器session
     * @param model 返回给客户端的数据
     * @return
     */
    @RequestMapping("/login")
    public String login(String username,String password,String redirectUrl, HttpSession session, Model model){
    
    

        System.out.println("login");
        // 模拟数据
        if("admin".equals(username) && password.equals("123456")){
    
    
            // 1.登录成功,创建一个token,
            String token = UUID.randomUUID().toString().replaceAll("-", "");
            System.out.println("gen token succes");
            // 2.保存token到数据库 ,这里模拟一下数据库
            MockDB.T_TOKEN.add(token);
            // 3. 在服务器中存在会话信息,和客户信息没有关系
            session.setAttribute("token", token);
            // 4. 客户端也要保存会话信息,这个很重要!!!!
            model.addAttribute("token", token);
            // 登录成功,返回到来时的地方!
            System.out.println("sso-server: redirectUrl=" + redirectUrl);
            return "redirect:" + redirectUrl; // 从哪来,回哪去 ?token=xxxx   http://www.tb.com:8002/taobao?token=6fd8678740a145c98e39324b820dde9f
        }
        // 登录失败,需要把重定向地址带到登陆页面
        model.addAttribute("redirectUrl", redirectUrl);
        // 登陆失败,返回登陆页面 login.jsp
        return "login";
    }

    /**
     *
     * @param redirectUrl
     * @param session
     * @param model
     * @return
     */
    @RequestMapping("/checkLogin")
    public String checkLogin(String redirectUrl, HttpSession session, Model model){
    
    
        System.out.println("start check is login");
      // 判断用户是否拥有全局的会话token
        //第一次登录,没有token   // 系统二登录,被重定向到这里来,发现系统一登录时已经产生过token了
        String token = (String)session.getAttribute("token");
        if(StringUtils.isEmpty(token)){
    
    
            // 没有全局会话,去登录页面,客户端请求时携带的redirectUrl也需要带上(我从哪里来不能丢掉了,登录页面需要带上这个参数去请求登录接口)
            model.addAttribute("redirectUrl",redirectUrl);
            System.out.println("redirect to login.jsp,redirectUrl=" + redirectUrl);
            return "login";
        } else {
    
    
            // 存在全局会话,返回到来的地方(客户端请求时携带的redirectUrl)
            //  回到客户端后,会产生一个cookie, 这个cookie是请求checkLogin的,系统二登录,请求头里面也是这个cookie值
            model.addAttribute("token",token);
            System.out.println("redirect to original page: " + redirectUrl +  "-"  + token);
            return "redirect:" + redirectUrl;
        }
    }

    @RequestMapping("/verify")
    @ResponseBody
    public String verifyToken(String token, String clientUrl, String jsessionId){
    
    
        if(MockDB.T_TOKEN.contains(token)){
    
    
            System.out.println("服务器端校验token通过");
            // 服务端验证通过,需要将客户端请求携带的sessionId和登出地址保存
            List<ClientInfo> clientInfoList = MockDB.T_CLIENT_INFO.get(token);
            if(null == clientInfoList){
    
    
                clientInfoList = new ArrayList<>();
                MockDB.T_CLIENT_INFO.put(token, clientInfoList);
            }
            ClientInfo clientInfo = new ClientInfo();
            clientInfo.setClientUrl(clientUrl);
            clientInfo.setJsessionId(jsessionId);
            clientInfoList.add(clientInfo);

            return "true";
        }
        return "false";
    }

    /**
     *  注销请求,需要注意的是,这里是手动点击注销按钮才会销毁session! 而session过期后,通知子系统注销会话的场景,这里不适用
     * @param session
     * @return
     */
    @RequestMapping("/logOut")
    public String logout(HttpSession session){
    
    
        session.invalidate();
        // 通知淘宝和天猫销毁session
        // 会话注销后,回到登录页面
        return "login";
    }
}
package com.ssotest.db;

import com.ssotest.pojo.ClientInfo;

import java.util.*;

/**
 * 模拟数据库存储
 */
public class MockDB {
    
    

    /**
     * 保存用户登录token
     */
    public static Set<String> T_TOKEN = new HashSet<>();
    /**
     * 保存用户登出信息,一个用户可能同时登录多个系统,map的key就是token,客户端登陆成功时,需要将 ClientInfoVO带给SSO认证中心
     */
    public static Map<String, List<ClientInfo>> T_CLIENT_INFO = new HashMap<>();
}
package com.ssotest.listener;

import com.ssotest.db.MockDB;
import com.ssotest.pojo.ClientInfo;
import com.ssotest.utils.HttpUtil;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;
import java.util.List;

public class MySessionListener implements HttpSessionListener {
    
    
    @Override
    public void sessionCreated(HttpSessionEvent se) {
    
    
    }

    /**
     * 销毁事件
     * @param se
     */
    @Override
    public void sessionDestroyed(HttpSessionEvent se) {
    
    
        HttpSession session = se.getSession();
        String token = (String) session.getAttribute("token");
        // 销毁用户信息
        // 1. 数据库清除用户token
        MockDB.T_TOKEN.remove(token);
        List<ClientInfo> clientInfoList = MockDB.T_CLIENT_INFO.remove(token);
        // 2. 遍历通知客户端注销
        for (ClientInfo clientInfo: clientInfoList){
    
    
            try {
    
    
                HttpUtil.sendHttpRequest(clientInfo.getClientUrl(), clientInfo.getJsessionId());
            } catch (Exception e) {
    
    
                e.printStackTrace();
            }
        }
    }
}
package com.ssotest.pojo;

/**
 * 前端传递用户登出地址和session到sso认证中心,认证中心保存到数据库中,以便登出时使用
 */
public class ClientInfo {
    
    
    /**
     * 用户登出地址
     */
    private String clientUrl;
    /**
     * 用户sessionId,每个用户sessionId不同
     */
    private String jsessionId;
	// getter setter 不贴了
}
package com.ssotest.utils;

import org.springframework.util.StreamUtils;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Map;

public class HttpUtil {
    
    
    /**
     * 模拟浏览器的请求
     */
    public static String sendHttpRequest(String httpURL,Map<String,String> params) throws Exception{
    
    
        URL url = new URL(httpURL);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setDoOutput(true);
        if(params!=null&&params.size()>0){
    
    
            StringBuilder sb = new StringBuilder();
            for(Map.Entry<String,String> entry:params.entrySet()){
    
    
                sb.append("&").append(entry.getKey()).append("=").append(entry.getValue());
            }
            conn.getOutputStream().write(sb.substring(1).toString().getBytes("utf-8"));
        }
        //发送请求到服务器
        conn.connect();
        String responseContent = StreamUtils.copyToString(conn.getInputStream(),Charset.forName("utf-8"));
        conn.disconnect();
        return responseContent;
    }
    /**
     * 模拟浏览器的请求
     */
    public static void sendHttpRequest(String httpURL,String jesssionId) throws Exception{
    
    
        URL url = new URL(httpURL);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setDoOutput(true);
        conn.addRequestProperty("Cookie","JSESSIONID="+jesssionId);
        //发送请求到服务器
        conn.connect();
        conn.getInputStream();
        conn.disconnect();
    }
}

spring配置文件

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/mvc
    https://www.springframework.org/schema/mvc/spring-mvc.xsd">

    <!-- 1.开启SpringMVC注解驱动 -->
    <mvc:annotation-driven />
    <!-- 2.静态资源默认servlet配置-->
    <mvc:default-servlet-handler/>

    <!-- 3.配置jsp 显示ViewResolver视图解析器 -->
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
        <property name="prefix" value="/WEB-INF/views/" />
        <property name="suffix" value=".jsp" />
    </bean>

    <!-- 4.扫描web相关的bean -->
    <context:component-scan base-package="com.ssotest" />

</beans>

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">
    <servlet>
        <servlet-name>DispatcherServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:applicationContext.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>DispatcherServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>


   <filter>
       <filter-name>encodingFilter</filter-name>
       <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
       <init-param>
           <param-name>encoding</param-name>
           <param-value>UTF-8</param-value>
       </init-param>
   </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    <!-- 注册监听器 -->
    <listener>
        <listener-class>com.ssotest.listener.MySessionListener</listener-class>
    </listener>
</web-app>

登录页面:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Sso-Server:登录</title>
    <link href="${pageContext.request.contextPath}/asserts/css/bootstrap.min.css" rel="stylesheet">
    <link href="${pageContext.request.contextPath}/asserts/css/signin.css" rel="stylesheet">
</head>

<body class="text-center">
<form class="form-signin" action="${pageContext.request.contextPath}/login">
    <%--redirectUrl:来时的地址不能丢--%>
    <input type="hidden" name="redirectUrl" value="${redirectUrl}">
    <h3 class="h3 mb-3 font-weight-normal">统一登录中心</h3>
    <input type="text" class="form-control" placeholder="Username" name="username" required="" autofocus="">
    <input type="password" class="form-control" placeholder="Password" name="password" required="">

    <div class="checkbox mb-3">
        <label><input type="checkbox" value="remember-me"> 记住我 </label>
    </div>
    <button class="btn btn-lg btn-primary btn-block" type="submit">登录</button>
</form>
</body>
</html>
4.3 淘宝服务端模拟
package com.ssotest.controller;

import com.ssotest.utils.SSOClientUtil;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpSession;

@Controller
public class LoginController {
    
    

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

    @RequestMapping("/taobao")
    public String index(Model model){
    
    
        // 返回SSO认证中心的登出地址(注销session)给前端页面
        model.addAttribute("logoutURL", SSOClientUtil.getServerLogOutUrl());
        return "taobao";
    }

    @RequestMapping("/logOut")
    public void logOut(HttpSession session) {
    
    
        session.invalidate();
    }
}
package com.ssotest.interceptor;

import com.ssotest.utils.HttpUtil;
import com.ssotest.utils.SSOClientUtil;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Objects;

public class SsoClientInterceptor implements HandlerInterceptor {
    
    
    // true:放行 false 拦截
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    
        // 1.判断是否存在会话
        HttpSession session = request.getSession();
        // isLogin 会话存在标识
        Boolean isLogin = (Boolean)session.getAttribute("isLogin");
        if(null !=isLogin && isLogin){
    
    
            return  true;  // 已登录,放行
        }

        // 2. 验证token
        String token = (String)request.getParameter("token");
        if(null!= token && !"".equals(token)){
    
    
            System.out.println("检测到服务器token信息"  );
            // 防止token伪造,拿到服务器端去验证
            // 服务器地址
            String httpUrl = SSOClientUtil.SERVER_URL_PREFIX + "/verify";
            // 需要验证的参数
            try {
    
    
                HashMap<String, String> params = new HashMap<>();
                // 客户端登陆成功!
                params.put("token", token);
                // 原来只把token携带给SSO认证中心,为了SSO登出,还需要将客户端登出地址clientUrl和sessionId给SSO认证中心存储
                // 客户端登出地址,实际上就是一个logout请求
                params.put("clientUrl", SSOClientUtil.getClientLogOutUrl());
                params.put("jsessionId", session.getId());
                // 请求SSO认证中心验证token 以及登录信息
               String verify =  HttpUtil.sendHttpRequest(httpUrl, params);
               if(Objects.equals("true", verify)){
    
    
                   // 服务器端校验token通过
                   System.out.println("服务器端校验token通过");
                   session.setAttribute("isLogin", true);
                   return true;
               }
            } catch (Exception e) {
    
    
                e.printStackTrace();
                System.out.println("验证异常");
            }
        }
        //  没有会话,跳转到统一认证中心,检测系统是否登录
        // http://www.sso.com:8001/checkLogin?redirectUrl=http://www.tb.com:8002
        System.out.println("没有检测到token,redirect to sso-server");
        SSOClientUtil.redirectToSSOURL(request, response);
        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 {
    
    
    }
}
package com.ssotest.utils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Properties;

public class SSOClientUtil {
    
    

    private static Properties ssoProperties = new Properties();
    public static String SERVER_URL_PREFIX; //统一认证中心地址
    public static String CLIENT_HOST_URL; //当前客户端地址

    static {
    
    
        try {
    
    
            ssoProperties.load(SSOClientUtil.class.getClassLoader().getResourceAsStream("sso.properties"));
        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
        SERVER_URL_PREFIX = ssoProperties.getProperty("server-url-prefix");
        CLIENT_HOST_URL = ssoProperties.getProperty("client-host-url");
    }

    /**
     * 当客户端请求被拦截,跳往统一认证中心,需要带redirectUrl的参数,统一认证中心登录后回调的地址
     */
    public static String getRedirectUrl(HttpServletRequest request){
    
    
        //获取请求URL
        return CLIENT_HOST_URL+request.getServletPath();
    }
    /**
     * 根据request获取跳转到统一认证中心的地址,通过Response跳转到指定的地址
     */
    public static void redirectToSSOURL(HttpServletRequest request,HttpServletResponse response) throws IOException {
    
    
        String redirectUrl = getRedirectUrl(request);
        StringBuilder url = new StringBuilder(50)
                .append(SERVER_URL_PREFIX)
                .append("/checkLogin?redirectUrl=")
                .append(redirectUrl);
        response.sendRedirect(url.toString());
    }
    /**
     * 获取客户端的完整登出地址
     */
    public static String getClientLogOutUrl(){
    
    
        return CLIENT_HOST_URL+"/logOut";
    }
    /**
     * 获取认证中心的登出地址
     */
    public static String getServerLogOutUrl(){
    
    
        return SERVER_URL_PREFIX+"/logOut";
    }
}

HttpUtil同sso-server 中的,不再贴了
spring配置文件中添加拦截器:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/mvc
    https://www.springframework.org/schema/mvc/spring-mvc.xsd">

    <!-- 1.开启SpringMVC注解驱动 -->
    <mvc:annotation-driven />
    <!-- 2.静态资源默认servlet配置-->
    <mvc:default-servlet-handler/>

    <!-- 3.配置jsp 显示ViewResolver视图解析器 -->
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
        <property name="prefix" value="/WEB-INF/views/" />
        <property name="suffix" value=".jsp" />
    </bean>

    <!-- 4.扫描web相关的bean -->
    <context:component-scan base-package="com.ssotest" />

    <!-- 5.配置拦截器检测用户是否登录 -->
    <mvc:interceptors>
        <mvc:interceptor>
            <mvc:mapping path="/**"/>
            <bean class="com.ssotest.interceptor.SsoClientInterceptor"></bean>
        </mvc:interceptor>
    </mvc:interceptors>
</beans>

sso.properties

# sso认证中心地址
server-url-prefix=http://www.sso.com:8001
# 客户端地址
client-host-url=http://www.tb.com:8002

web.xml配置

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <servlet>
        <servlet-name>DispatcherServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:applicationContext.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>DispatcherServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>


    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- 会话过期时间  -->
    <session-config>
        <session-timeout>5</session-timeout>
    </session-config>
</web-app>

taobao.jsp页面

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
  <head>
    <title>淘宝首页</title>
  </head>
  <body>
    <h1>淘宝首页</h1>
    <p>当前登陆的用户 <span>admin</span></p>
    <a href="${logoutURL}">注销</a>
  </body>
</html>
4.4 天猫服务端模拟
package com.ssotest.controller;

import com.ssotest.utils.SSOClientUtil;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpSession;

@Controller
public class LoginController {
    
    

    @RequestMapping("/tmall")
    public String index(Model model) {
    
    
        model.addAttribute("logoutURL", SSOClientUtil.getServerLogOutUrl());
        return "tmall";
    }

    @RequestMapping("/logOut")
    public void logOut(HttpSession session) {
    
    
        session.invalidate();
    }
}
package com.ssotest.interceptor;

import com.ssotest.utils.HttpUtil;
import com.ssotest.utils.SSOClientUtil;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Objects;

/**
 * SSO单点登陆客户端
 */
public class SsoClientInterceptor implements HandlerInterceptor {
    
    

    // true:放行 false 拦截
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    
        // 1.判断是否存在会话
        HttpSession session = request.getSession();
        // isLogin 会话存在标识
        Boolean isLogin = (Boolean)session.getAttribute("isLogin");
        if(null !=isLogin && isLogin){
    
    
            return  true;  // 已登录,放行
        }

        // 2. 验证token
        String token = (String)request.getParameter("token");
        if(null!= token && !"".equals(token)){
    
    
            System.out.println("检测到服务器token信息");
            // 防止token伪造,拿到服务器端去验证
            // 服务器地址
            String httpUrl = SSOClientUtil.SERVER_URL_PREFIX + "/verify";
            // 需要验证的参数
            try {
    
    
                HashMap<String, String> params = new HashMap<>();
                params.put("token", token);
                // SSO登出,还需要将客户端登出地址clientUrl和sessionId给SSO认证中心存储
                params.put("clientUrl", SSOClientUtil.getClientLogOutUrl());
                params.put("jsessionId", session.getId());
               String verify =  HttpUtil.sendHttpRequest(httpUrl, params);
               if(Objects.equals("true", verify)){
    
    
                   // 服务器端校验token通过
                   System.out.println("服务器端校验token通过");
                   session.setAttribute("isLogin", true);
                   return true;
               }
            } catch (Exception e) {
    
    
                e.printStackTrace();
                System.out.println("验证异常");
            }
        }

        //  没有会话,跳转到统一 认证中心,检测系统是否登录
        // http://www.sso.com:8001/checkLogin?redirectUrl=http://www.tb.com:8002
        System.out.println("没有检测到token,redirect to sso-server");
        SSOClientUtil.redirectToSSOURL(request, response);
        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 {
    
    
    }
}

HttpUtil SSOClientUtil 与前面的相同
spring配置文件

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd
    http://www.springframework.org/schema/mvc
    https://www.springframework.org/schema/mvc/spring-mvc.xsd">

    <!-- 1.开启SpringMVC注解驱动 -->
    <mvc:annotation-driven />
    <!-- 2.静态资源默认servlet配置-->
    <mvc:default-servlet-handler/>

    <!-- 3.配置jsp 显示ViewResolver视图解析器 -->
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
        <property name="prefix" value="/WEB-INF/views/" />
        <property name="suffix" value=".jsp" />
    </bean>

    <!-- 4.扫描web相关的bean -->
    <context:component-scan base-package="com.ssotest" />

    <!-- 5.配置拦截器检测用户是否登录 -->
    <mvc:interceptors>
        <mvc:interceptor>
            <mvc:mapping path="/**"/>
            <bean class="com.ssotest.interceptor.SsoClientInterceptor"></bean>
        </mvc:interceptor>
    </mvc:interceptors>

</beans>

sso.properties

# sso认证中心地址
server-url-prefix=http://www.sso.com:8001
client-host-url=http://www.tm.com:8003

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <servlet>
        <servlet-name>DispatcherServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:applicationContext.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>DispatcherServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>


    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

tmall.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
  <head>
    <title>天猫首页</title>
  </head>
  <body>
    <h1>天猫首页</h1> <hr/>
    <p>当前登陆的用户 <span>admin</span></p>
    <a href="${logoutURL}">注销</a>
  </body>
</html>

最后,需要在本机添加一下hosts 模拟跨域登录

127.0.0.1 www.sso.com
127.0.0.1 www.tb.com
127.0.0.1 www.tm.com
4.5 登录流程分析

1.sso-client首次登录天猫,拦截器检测到没有isLogin为空,重定向到sso-server的checkLogin接口,同时带上redirectUrl
2.sso-server checkLogin接口检测到没有会话,带上redirectUrl重定向到login接口,login接口中判断用户信息是否登录,未登录,重定向到登陆页面login.jsp,带上redirectUrl
3.用户登录,带上隐藏域的redirectUrl,请求SSO-server的登陆接口 ,登录接口判断用户信息是否正确,正确,生成一个token保存到数据库并返回给客户端,然后重定向到redirectUrl的地址,这时会带上token,例如http://www.tb.com:8002/taobao?token=6fd8678740a145c98e39324b820dde9f

4.sso-client 请求http://www.tb.com:8002/taobao,此时客户端浏览器是存储了token的(jeseesionId),浏览器去请求sso-server时,会自动携带cookie信息,拦截器里面通过获取请求参数中的token并去sso-server验证token是否存在,服务端验证token通过,将sso-client会话标识isLogin设置为true,拦截器返回true,请求到达接口,接口处理业务逻辑

5.用户首次在淘宝登录,请求http://www.tb.com:8002/taobao :被拦截器拦截,检查是否存在会话,首次登录,没有会话和token信息,重定向到sso认证中心(带上redirectUrl去请求认证中心的/checkLogin接口),具体过程如下:
在这里插入图片描述
淘宝重定向到sso认证中心的checkLogin接口,接口中检测到天猫登录时已经产生过token了(也就是存在一个全局会话了),然后就带上这个token重定向到淘宝登录地址:http://www.tb.com:8002/taobao?token=acb1cb2017cf4ed0a4122a3f1cbfd81d
在这里插入图片描述
紧接着,淘宝拿着这个token去登录,淘宝的拦截器检测到token不为空,去sso认证中心验证token,认证中心认证通过,则拦截器放行,并设置登录标记为true,放到session中,接口请求 /taobao,返回到淘宝的login成功页面
在这里插入图片描述
首次登录天猫时,天猫网站拦截器重定向到sso认证中心:携带的cookie:JSESSIONID=552616418397EDBD89EB16C561AFD49F
在这里插入图片描述
天猫登录成功后,访问淘宝,这时请求重定向到sso认证中心时请求头携带的cookie值和登陆天猫时重定向到sso认证中心时携带的请求头cookie是一样的!!!!
在这里插入图片描述

IDEA配置tomcat启动web项目: 添加链接描述

猜你喜欢

转载自blog.csdn.net/weixin_41300437/article/details/107134145