SSO 單點登入

LZC發表於2019-08-31

[TOC]

SSO

單點登入英文全稱Single Sign On,簡稱就是SSO。它的解釋是:在多個應用系統中,只需要登入一次,就可以訪問其他相互信任的應用系統。

普通登入

在說單點登入之前,先來看看普通的登入認證實現方式:

@GetMapping("/login")
    public Object login(@RequestParam String username,
                        @RequestParam String password,
                        HttpServletRequest request) {
        Map<String, Object> result = new HashMap<>();
        if ("zhangsan".equals(username) && "123456".equals(password)) {
            // 登入成功
            // 將使用者資訊儲存到session中
            HttpSession session = request.getSession();
            session.setAttribute("LOGIN_USERNAME",username);
            result.put("code","0000");
            result.put("msg", "登入成功");
        } else {
            // 登入失敗
            result.put("code","0001");
            result.put("msg", "登入失敗");
        }
        return result;
    }

    @GetMapping("/getUserInfo")
    public Object getUserInfo(HttpServletRequest request) {
        Map<String, Object> result = new HashMap<>();
        HttpSession session = request.getSession();
        String username = (String)session.getAttribute("LOGIN_USERNAME");

        if (username == null) {
            result.put("code","0001");
            result.put("msg", "使用者未登入");
        } else {
            result.put("code","0001");
            result.put("msg", "使用者已登入");
            result.put("data", username);
        }
        return result;
    }

上面的程式碼主要實現了一個簡單的登入功能以及獲取登入使用者的資訊,可以發現,當使用者登入成功後,我們把使用者的資訊儲存在session中,當獲取登入使用者資訊的時候也是通過session進行獲取。假設現在有N個不同的使用者在N個不同的瀏覽器進行登入,當這N個使用者都去獲取當前的使用者資訊時,服務端又是如何根據不同的登入使用者返回不同的使用者資訊呢?這裡就需要了解一下cookie與session的工作原理了。

cookie和session

首先需要知道的是:cookie的資訊儲存在瀏覽器,session的資訊儲存在伺服器(比如你用的是Tomcat伺服器,那麼你的session資訊就是儲存在Tomcat中)。

cookie

我們都知道,http請求是無狀態的。也就是說我們第一次和伺服器連線並且登入成功,第二次請求伺服器的時候,伺服器是不知道當前請求是哪個使用者的。cookie的出現就是為了解決這個問題,第一次登入成功後伺服器會返回一些資料(cookie)給瀏覽器,然後瀏覽器將cookie資訊儲存在本地,當該使用者傳送第二次請求的時候,瀏覽器會攜帶該請求可以攜帶的cookie資訊去訪問伺服器,伺服器通過瀏覽器攜帶的資料就能判斷當前使用者是哪個。

需要說明一下:當使用者請求伺服器的時候,它可以攜帶的cookie資訊是有條件的,這裡就需要了解一下cookie的domain屬性和path屬性了。

domain:通過設定domain這個屬性可以使多個web伺服器共享cookie。domain屬性的預設值是建立cookie的伺服器的主機名,不能把cookie的domain設定成伺服器所在的域之外的域。為了便於理解,這裡舉一個簡單的例子:

假設現在有一個頂級域名lzc.com和兩個二級域名a.lzc.com、b.lzc.com。頂級域名只能將domain設定為頂級域名,二級域名只能將domain設定為它的頂級域名或者是本身。當我們訪問a.lzc.com伺服器的時候想攜帶b.lzc.com伺服器所設定的cookie資訊,那麼b.lzc.com伺服器設定cookie的domain屬性就必須為domain。當某個cookie的domain設定為lzc.com,那麼在訪問lzc.com、a.lzc.com、b.lzc.com服務的時候,這個cookie都會被攜帶上去。

path:可以訪問此cookie的頁面路徑。 比如domain是tanbao.com,path是/test,那麼只有/test路徑下的頁面可以讀取此cookie。

session

session和cookie的作用是相似的,都是為了儲存資訊。不同的是,cookie資訊是儲存在本地瀏覽器,而session資訊儲存在伺服器。儲存在伺服器的資訊更加安全,不容器被竊取。

cookie和session結合

通過上面的登入場景來說明cookie和session是如何結合工作的。

當使用者登入成功時,通過呼叫HttpServletRequest.getSession()獲取session物件,通過session.setAttribute()來儲存我們的使用者資訊。如果是首次呼叫HttpServletRequest.getSession(),則會建立一個session物件,並通過某些演算法得到sessionid,sessionid用來標識這個session物件,並把這個sessionid儲存在cookie中,cookie的name為JSESSIONID,cookie的value為sessionid的值。HttpServletRequest.getSession()就是通過sessionid來返回不同的session物件。

當瀏覽器第二次訪問伺服器獲取使用者資訊時,通過HttpServletRequest.getSession()獲取session物件,它通過獲取cookie中JSESSIONID的值來獲取對應的session物件,進而獲取到使用者的登入資訊。

同域下的單點登入

同域下的單點登入比較簡單,通過設定cookie的domain來實現cookie的跨域,使用spring session可以實現session共享。

不同域下的單點登入

單點登入需要一個獨立的認證中心,系統的登入都是跳轉到認證中心來登入。

假設現在有三個系統,這三個系統的域名都是不同的,如a.com、b.com、sso.com,分別為系統A、系統B、系統SSO,SSO為認證中心。

  1. 當使用者訪問系統A的受保護資源,系統A發現使用者未登入,就重定向到SSO認證中心進行登入,並將請求地址作為引數攜帶過去。
  2. 到達SSO認證中心後,發現該使用者沒有在SSO認證中心登入過,然後將頁面跳轉到登入頁面。
  3. 使用者輸入使用者名稱和密碼並提交登入申請。
  4. 若登入成功,將建立使用者與SSO認證中心的會話,該會話說明使用者已經在SSO認證中心登入過了,同時建立一個令牌,SSO認證中心攜帶該令牌跳轉到最初的請求地址(系統A)。
  5. 系統A拿到令牌後,拿著這個令牌去SSO認證中心進行驗證,若認證成功,建立使用者與系統A的會話,即表明使用者在系統A登入過了。
  6. 接下來使用者訪問系統B,系統B發現使用者未登入,則將頁面重定向到SSO認證中心並將請求地址作為引數,SSO認證中心發現使用者已經在這裡登入過了,獲取上次登入時產生的令牌,攜帶該令牌跳轉系統B。
  7. 系統B拿到令牌後,去SSO認證中心驗證該令牌是否有效,如果有效,則建立使用者與系統B的會話,即表明使用者在系統B登入過了。

程式碼實現

sso-server

@Controller
@Slf4j
public class LoginController {

    // 用來存放生成的token,這個可以存放在Redis裡面,我這裡為了方便就直接存放在記憶體裡面了
    public static Map<String, Object> tokens = new HashMap<>();

    @GetMapping("/login")
    public String login(HttpServletRequest request, Model model) {
        String requestUrl = request.getParameter("requestUrl");
        // 首先判斷該使用者是否在sso登入過
        String token = (String) request.getSession().getAttribute("TOKEN");
        if (token != null) {
            log.info("使用者已經登入過了");
            // 如果token不為空,則說明該使用者已經登入過了
            return "redirect:" + requestUrl + "?token=" + token;
        }
        model.addAttribute("requestUrl", requestUrl);
        log.info("使用者未登入,跳轉到登入頁面");
        return "login";
    }

    @PostMapping("/dologin")
    public String dologin(HttpServletRequest request) {
        // 這裡省略登入驗證過程
        String token = UUID.randomUUID().toString();
        // 設定該使用者為登入狀態
        request.getSession().setAttribute("TOKEN", token);
        // 儲存token,並設定對應的使用者資訊,後面對token的驗證都是基於這個來驗證的
        tokens.put(token, request.getParameter("username"));
        return "redirect:" + request.getParameter("requestUrl") + "?token=" + token;
    }

    @GetMapping("/verify")
    @ResponseBody
    public Object verify(HttpServletRequest request) {
        Map<String, Object> map = new HashMap<>();
        String token = (String) request.getParameter("token");

        if (tokens.containsKey(token)) {
            map.put("code","0000");
            map.put("msg","認證成功");
            map.put("username", tokens.get(token));
            log.info("認證成功");
            return map;
        }
        map.put("code","0001");
        map.put("msg","認證失敗");
        log.info("認證失敗");
        return map;
    }
}

sso-client攔截未登入請求

攔截方法有多重,我這裡使用的是過濾器。使用的是SpringBoot工程,使用攔截器時,在啟動類上加一個註解:@ServletComponentScan,過濾器程式碼如下所示

@WebFilter(urlPatterns = "/user/*",filterName = "LoginFilter")
@Slf4j
public class LoginFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        // 判斷是否登入
        String token = (String)request.getSession().getAttribute("TOKEN");
        if (token != null) {
            filterChain.doFilter(servletRequest, servletResponse);
        } else {
            // 再次判斷是否為sso攜帶token跳轉過來的
            token = request.getParameter("token");
            if (token != null) {
                // 拿這個token去sso進行驗證
                Map result = verify(token, request);
                log.info(JSON.toJSONString(result));
                if (result.get("code").toString().equals("0000")) {
                    // 認證成功
                    // 將當前使用者設定為已登入狀態
                    request.getSession().setAttribute("TOKEN", token);
                    request.getSession().setAttribute("USERNAME", result.get("username"));
                    filterChain.doFilter(servletRequest, servletResponse);
                    return;
                } else {
                    // 認證失敗
                    response.sendRedirect("http://www.sso.com:8080/login?requestUrl=" + request.getRequestURL());
                    return;
                }
            }
            response.sendRedirect("http://www.sso.com:8080/login?requestUrl=" + request.getRequestURL());
            return;
        }
    }

    private Map verify(String token, HttpServletRequest request) {
        RestTemplate restTemplate = new RestTemplate();
        Map forEntity = restTemplate
                .getForObject("http://www.sso.com:8080/verify?token=" + token, Map.class);
        return forEntity;
    }

    @Override
    public void destroy() {

    }
}

相關文章