Vue + Spring Boot 專案實戰(十四):使用者認證方案與完善的訪問攔截

Evan-Nightly發表於2020-01-19

重要連結:
「系列文章目錄」

「專案原始碼(GitHub)」

前言

之前我老說自己寫文章不容易,一篇有時候要搞七八個小時,想到大多數人恐怕沒我這麼用心,就偷懶偷得比較心安理得。但最近刷 B 站,發現一些 UP 主居然會花上百個小時去剪一個三分鐘的視訊,我了個乖乖,雖說寫文章不像他們發視訊那樣能掙錢,但是我還是被他們這種為夢想爆肝的精神感動了,活該人家成功(tū tóu)啊。不過感動歸感動,對於我來說寫文章是一種興趣,要是做到這種程度恐怕就沒什麼幸福感了。如果每篇文章都是這樣的篇幅,兩週一更其實壓力也並不小,畢竟除去開頭結尾吹牛扯皮和程式碼,一篇教程最起碼有三千字是要字句斟酌的。

本篇文章主要內容如下:

  • 登出功能開發
  • 使用者認證機制詳解(session、token)
  • 通過前後端的配合實現完善的訪問攔截
  • 進一步分析 Shiro 的工作機制

一、登出功能開發

有的同學可能已經發現了,我們的系統一直沒有登出功能。其實按照過去的登入驗證方法,伺服器端並不會記住 “登入成功” 的狀態,也就是說,是否執行這個方法對伺服器來說並沒有什麼區別,使用者完全可以不用登入自行構造請求訪問後端的各種資源。僅僅依靠前端的一些判斷,只能騙騙不懂計算機的小朋友們。

之前的文章 「Vue + Spring Boot 專案實戰(六):前端路由與登入攔截器」 中分別講解了前端攔截和後端攔截,在我們這個前後端分離的破專案中,必須把這兩種方式結合起來,才能實現真正意義上的訪問控制。(這也體現了前後端分離令人蛋疼之處)

在我們引入 Shiro 作為安全框架之後,擁有了對登入狀態進行管理的能力,這時,我們才能實現真正意義上的登入和登出。登入上節已經講過了,這裡我們簡單實現一下登出。

1.後端

後端程式碼如下:

    @ResponseBody
    @GetMapping("api/logout")
    public Result logout() {
        Subject subject = SecurityUtils.getSubject();
        subject.logout();
        String message = "成功登出";
        return ResultFactory.buildSuccessResult(message);
    }

核心就是 subject.logout(),預設 Subject 介面是由 DelegatingSubject 類實現,其 logout 方法如下:

    public void logout() {
        try {
            this.clearRunAsIdentitiesInternal();
            this.securityManager.logout(this);
        } finally {
            this.session = null;
            this.principals = null;
            this.authenticated = false;
        }

    }

可以看出,該方法會清除 session、principals,並把 authenticated 設定為 false。有興趣的同學可以再看看 SecurityManager 中做了什麼,這裡就不贅述了。

之前我們在後端配置了攔截器,由於登出功能不需要被攔截,所以我們還需要修改配置類 MyWebConfigureraddInterceptors() 方法,新增一條路徑:

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(getLoginIntercepter())
                .addPathPatterns("/**")
                .excludePathPatterns("/index.html")
                .excludePathPatterns("/api/login")
                .excludePathPatterns("/api/logout");
    }

2.前端

前端要做的事情有兩件,一是顯示,二是邏輯。原來我們一直講前端簡單後端複雜,但現在前端能做的事情太多了,誰複雜還真不好說。而且過去十幾年各種後端語言此起彼伏,但 JavaScript 一枝獨秀穩坐泰山,所以學它肯定不虧。雖然我們的教程講 Vue,但是我還是想提醒一下各位興趣在前端的小夥伴,不要只會用框架,框架一浪拍一浪死的很快,原生 JS 才是你的立身之本。

顯示這裡我簡單寫一下,在原來頂部的導航欄里加一個登出按鈕,如圖:
在這裡插入圖片描述
寫在 <el-menu> 標籤裡即可:

<i class="el-icon-switch-button" v-on:click="logout" style="float:right;font-size: 40px;color: #222;padding: 10px"></i>

調整樣式:

  .el-icon-switch-button {
    cursor: pointer;
    outline:0;
  }

在 methods 中編寫 logout() 方法:

      logout () {
        var _this = this
        this.$axios.get('/logout').then(resp => {
          if (resp.data.code === 200) {
            // 前後端狀態保持一致
            _this.$store.commit('logout')
            _this.$router.replace('/login')
          }
        })
      }

在 store 中定義 logout 方法:

    logout (state) {
      state.user = []
      window.localStorage.removeItem('user')
    }

這樣,登出功能就開發完成了。

二、完善的訪問攔截

現在,雖然我們登入登出的狀態沒問題了,但是還有關鍵的一步沒有做。上篇文章的最後,我們說可以通過在控制檯輸入類似

window.localStorage.setItem('user', JSON.stringify({"name":"哈哈哈"}));

的命令來繞過前端的 “全域性前置守衛”(router.beforeEach),所以要想真正實現登入攔截,必須在後端也判斷使用者是否登入以及登入的是哪個瓜皮使用者,而這就需要前端向後端傳送使用者資訊。

說到這裡,我感到頭皮掠過一絲涼意,因為關於這個使用者資訊如何表示、如何儲存、如何驗證是一個大問題,講明白不容易。你也許能在網上搜到許多講這個的文章,比我寫的好的有很多,但我發現瞎說的更多,所以我希望能給讀者們講清楚,不要被其它文章帶跑偏了。

1.認證方案(session 與 token)

先說最簡單的認證方法,即前端在每次請求時都加上使用者名稱和密碼,交由後端驗證。這種方法的弊端有兩個:

  • 一,需要頻繁查詢資料庫,導致伺服器壓力較大
  • 二,安全性,如果資訊被擷取,攻擊者就可以 一直 利用使用者名稱密碼登入(注意不是因為明文不安全,是由於無法控制時效性)

為了在某種程度上解決上述兩個問題,有兩種改進的方案 —— session 與 token。

- session

許多語言在網路程式設計模組都會實現會話機制,即 session。利用 session,我們可以管理使用者狀態,比如控制會話存在時間,在會話中儲存屬性等。其作用方式通常如下:

  • 伺服器接收到第一個請求時,生成 session 物件,並通過響應頭告訴客戶端在 cookie 中放入 sessionId
  • 客戶端之後傳送請求時,會帶上包含 sessionId 的 cookie
  • 伺服器通過 sessionId 獲取 session ,進而得到當前使用者的狀態(是否登入)等資訊

也就是說,客戶端只需要在登入的時候傳送一次使用者名稱密碼,此後只需要在傳送請求時帶上 sessionId,伺服器就可以驗證使用者是否登入了。

session 儲存在記憶體中,在使用者量較少時訪問效率較高,但如果一個伺服器儲存了幾十幾百萬個 session 就十分難頂了。同時由於同一使用者的多次請求需要訪問到同一伺服器,不能簡單做叢集,需要通過一些策略(session sticky)來擴充套件,比較麻煩。

之前見過有的人把 sessionId 持久化到資料庫裡,只存個 id,大頭還在記憶體裡,這個操作我是看不懂的。。。

- token

雖然 session 能夠比較全面地管理使用者狀態,但這種方式畢竟佔用了較多伺服器資源,所以有人想出了一種無需在伺服器端儲存使用者狀態(稱為 “無狀態”)的方案,即使用 token(令牌)來做驗證。

對於 token 的理解,比較常見的誤區是:

  • 生成方面,使用隨機演算法生成的字串、裝置 mac 地址甚至 sessionId 作為 token。雖然從字面意思上講這些也算是“令牌”,但是毫無意義。這是真的 “沒有狀態” 了,對於狀態的控制甚至需要用 session 完成,那隻用 session 不好嗎?
  • 驗證方面,把 token 儲存在 session 或資料庫中,比對前端傳來的 token 與儲存的 token 是否一致。鬼鬼,同樣的騷操作。更騷的是為了提高比對效率把 token 儲存在 redis 中,大家一看哇偶好高階好有道理,就直接採用這種方法了,甚至都懶得想 token 到底是個什麼。。

簡單來說,一個真正的 token 本身是攜帶了一些資訊的,比如使用者 id、過期時間等,這些資訊通過簽名演算法防止偽造,也可以使用加密演算法進一步提高安全性,但一般沒有人會在 token 裡儲存密碼,所以不加密也無所謂,反正被截獲了結果都一樣。(一般會用 base64 編個碼,方便傳輸)

在 web 領域最常見的 token 解決方案是 JWT(JSON Web Token),其具體實現可以參照官方文件,這裡不再贅述。

token 的安全性類似 session 方案,與明文密碼的差異主要在於過期時間。其作用流程也與 session 類似:

  • 使用者使用使用者名稱密碼登入,伺服器驗證通過後,根據使用者名稱(或使用者 id 等),按照預先設定的演算法生成 token,其中也可以封裝其它資訊,並將 token 返回給客戶端(可以設定到客戶端的 cookie 中,也可以作為 response body)
  • 客戶端接收到 token,並在之後傳送請求時帶上它(利用 cookie、作為請求頭或作為引數均可)
  • 伺服器對 token 進行解密、驗證

最後再強調一下:

token 的優勢是無需伺服器儲存!!!
token 的優勢是無需伺服器儲存!!!
token 的優勢是無需伺服器儲存!!!

不要再犯把 token 儲存到 session 或是資料庫中這樣的錯誤了。

2.客戶端儲存方案 (cookie、localStorage、sessionStorage)

接下來說一下認證資訊在 客戶端 儲存的方式。首先明確,無論是明文使用者名稱密碼,還是 sessionId 和 token,都可以用三種方式儲存,即 cookie、localStorage 和 sessionStorage。

但 cookie 和 local/session Storage 分工又有所不同,cookie 可以作為傳遞的引數,並可通過後端進行控制,local/session Storage 則主要用於在客戶端中儲存資料,其傳輸需要藉助 cookie 或其它方式完成。

下面是三種方式的對比(參考文章 JS 詳解 Cookie、 LocalStorage 與 SessionStorage

特性 cookie localStorage sessionStorage
生命週期 一般由伺服器生成,可設定失效時間。如果在瀏覽器端生成cookie,預設是關閉瀏覽器後失效 除非被清除,否則永久儲存 僅在當前會話下有效,關閉頁面或瀏覽器後被清除
資料大小 4K左右 一般為5MB 一般為5MB
通訊方式 每次都會攜帶在HTTP頭中,如果使用cookie儲存過多資料會帶來效能問題 僅在客戶端(即瀏覽器)中儲存,不參與和伺服器的通訊 同 localStorage

通常來說,在可以使用 cookie 的場景下,作為驗證用途進行傳輸的使用者名稱密碼、sessionId、token 直接放在 cookie 裡即可。而後端傳來的其它資訊則可以根據需要放在 local/session Storage 中,作為全域性變數之類進行處理。

3.後端登入攔截

終於可以粘程式碼了,但是在這之前還得再說兩句。shiro 的安全管理實際上是基於會話實現的,所以我們沒得選,用 session 方案就可以了。網上居然還有說 shiro + token 的,唉,這個問題槽點怎麼這麼多。。。

上節課我們分析了 subject.login() 背後的故事,但還有一點沒說,就是該過程會產生 session,並自動把 sessionId 設定到 cookie。這個看 DelegatingSubject 類還看不出來,還要再繼續深入分析原始碼。我們簡單做個測試,使用 postman 發一個登入請求:
在這裡插入圖片描述
可以看到響應頭中第一行設定的 JSESSIONID ,即 sessionId 在 tomcat 中的叫法。

好了,接下來我們來實現一下較為完善的訪問攔截。上面說過,靠前端實現的攔截很容易被繞過。要想實現靠譜的攔截,必須由後端驗證使用者登入狀態。這個思路並不難,就是前端帶上 sesisonId 傳送請求交由後端 認證,坑爹之處主要在於前後端分離的情況下需要額外的配置解決跨域問題。

預設的情況下,跨域的 cookie 是被禁止的,後端不能設定,前端也不能傳送,所以兩邊都要設定。

首先編寫一下攔截器 LoginInterceptor,主要是修改 preHandle 方法

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {

        // 放行 options 請求,否則無法讓前端帶上自定義的 header 資訊,導致 sessionID 改變,shiro 驗證失敗
        if (HttpMethod.OPTIONS.toString().equals(httpServletRequest.getMethod())) {
            httpServletResponse.setStatus(HttpStatus.NO_CONTENT.value());
            return true;
        }

        Subject subject = SecurityUtils.getSubject();
        // 使用 shiro 驗證
        if (!subject.isAuthenticated()) {
            return false;
        }
        return true;
    }

由於跨域情況下會先發出一個 options 請求試探,這個請求是不帶 cookie 資訊的,所以 shiro 無法獲取到 sessionId,將導致認證失敗。這個地方坑了我好幾個小時,要不是文章早就寫完了,我什麼都配置好了,請求發過來 sessionId 還是老變。也怪我一直盯著後端,沒仔細看前端發的是啥請求。

之後,為了允許跨域的 cookie,我們需要在配置類 MyWebConfigurer 做一些修改,主要是 addCorsMappings 方法:

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowCredentials(true)
                .allowedOrigins("http://localhost:8080")
                .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
                .allowedHeaders("*")
    }

這裡注意,在 allowCredentials(true) ,即允許跨域使用 cookie 的情況下,allowedOrigins() 不能使用萬用字元 *,這也是出於安全上的考慮。

4.前端配置

為了讓前端能夠帶上 cookie,我們需要通過 axios 主動開啟 withCredentials 功能,即在 main.js 中新增一行

axios.defaults.withCredentials = true

這樣,前端每次傳送請求時就會帶上 sessionId,shiro 就可以通過 sessionId 獲取登入狀態並執行是否登入的判斷。

現在還存在一個問題,即後端介面的攔截是實現了,但頁面的攔截並沒有實現,仍然可以通過偽造引數,繞過前端的路由限制,訪問本來需要登入才能訪問的頁面。為了解決這個問題,我們可以修改 router.beforeEach 方法:

router.beforeEach((to, from, next) => {
    if (to.meta.requireAuth) {
      if (store.state.user) {
        axios.get('/authentication').then(resp => {
          if (resp) next()
        })
      } else {
        next({
          path: 'login',
          query: {redirect: to.fullPath}
        })
      }
    } else {
      next()
    }
  }
)

即訪問每個頁面前都向後端傳送一個請求,目的是經由攔截器驗證伺服器端的登入狀態,防止上述情況的發生。後端這個介面可以暫時寫成空的,比如:

    @ResponseBody
    @GetMapping(value = "api/authentication")
    public String authentication(){
        return "身份認證成功";
    }

mark 一下這種思路,後來在許可權控制中還要發揮重要作用。

5.rememberMe

上文提到 cookie 的生命週期如果未特別設定則與瀏覽器保持一致。我們看一下之前傳送的登入請求的響應
在這裡插入圖片描述
JSESSIONID=C9D7C13C4C2444022AD865A23CA96189; Path=/; HttpOnly

並沒有設定存活時間,所以在關閉瀏覽器後,sessionId 就會消失,再次傳送請求,shiro 就會認為使用者已經變更。但有時我們需要保持登入狀態,不然每次都要重新登入怪麻煩的,所以 shiro 提供了 rememberMe 機制。

rememberMe 機制不是單純地設定 cookie 存活時間,而是又單獨儲存了一種新的狀態。之所以這樣設計,也是出於安全性考慮,把 “記住我” 的狀態與實際登入狀態做出區分,這樣,就可以控制使用者在訪問不太敏感的頁面時無需重新登入,而訪問類似於購物車、訂單之類的頁面時必須重新登入。

為了啟用 rememberMe,我們需要修改 shiro 配置類,新增兩個方法:

    public CookieRememberMeManager rememberMeManager() {
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        cookieRememberMeManager.setCookie(rememberMeCookie());
        cookieRememberMeManager.setCipherKey("EVANNIGHTLY_WAOU".getBytes());
        return cookieRememberMeManager;
    }

    @Bean
    public SimpleCookie rememberMeCookie() {
        SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
        simpleCookie.setMaxAge(259200);
        return simpleCookie;
    }

之後,在登入方法中設定 UsernamePasswordTokenrememberMe 屬性

···
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, requestUser.getPassword());
usernamePasswordToken.setRememberMe(true);
try {
    subject.login(usernamePasswordToken);
    ···
    }

這時再看登入方法的響應頭,發現多了一條關於 rememberMe 的設定

在這裡插入圖片描述

裡面有我們配置的存活時間 Max-Age=259200,單位是秒,259200 即 30 天。

在攔截器中進行具體的判斷邏輯,由於目前我們並沒有特殊需求,所以姑且兩種狀態都放行:

 if (!subject.isAuthenticated() && !subject.isRemembered()) {
     return false;
 }

可以通過

 System.out.println(subject.isRemembered());
 System.out.println(subject.isAuthenticated());

測試一下,當正常登入時,控制檯的輸出為
在這裡插入圖片描述
關閉瀏覽器,直接訪問需要登入的頁面,仍然可以進入,但控制檯的輸出為
在這裡插入圖片描述
此外,可以通過在登入時設定單選框的方式,讓使用者自行決定是否啟用記住我功能。

下一步

希望大家已經大致弄清楚 shiro 的作用以及如何在前後端之前傳遞認證相關的資訊了。下一篇計劃講解根據使用者許可權動態渲染頁面(選單),又是一個大工程,我儘量在兩週之內寫完吧,再次感謝大家的支援與耐心等待。

再多聊幾句,湊個字數。近期有三個人問我怎麼理財,他們分別是我的讀者、同事和高中同學,我給的建議核心都是倆字:存錢。 你不要去相信網上那些鋪天蓋地的理財教程,什麼複利是世界第八大奇蹟,股市是年輕人的又一次躍升機會之類,要真這樣他們哪還有功夫教你,都趕去躍升了。這些課程都是漏斗模型,層層篩選出最傻最好騙的韭菜,有點良心的無非是賣貴點的課給你,不要節操的會讓你貸款上槓杆炒幣炒空氣炒到懷疑人生。

理財是必要的,但它的意義在於 財務安全,即通過一定的知識讓自身或者家庭不陷入由經濟問題引起的危機。想要靠我們們手裡的這點錢理財理出財務自由,難度堪比上小學的盧姥爺和 faker 打成五五開。人人都知道股市的二八定律,然而人人都以為自己是那能掙錢的兩成人。可以說當你從沒接觸過股市卻抱有這種期待的那一刻起,你就成為了一棵合格的韭菜。

財務安全,一是要管控風險,即自己不瞭解的東西不碰,比如一個理財產品收益特別高,還號稱沒有風險,你想不明白為什麼,就離它遠遠的。二是要未雨綢繆,為未來大概率出現的事情做準備,比如步入職場你就要考慮買房結婚買車生子教育父母養老。頭一兩年沒心沒肺玩一玩無所謂,大家都是這麼過來的,但這些問題你越早面對,將來承擔的壓力就越小。其實本來大家自發地也都能學會存錢、穩穩過日子,奈何現在揮舞鐮刀的人越來越多,能保證自己不被收割就算是大智慧嘍。

最後,如果大家覺得我的文章有所幫助,可以點贊關注收藏評論什麼的走一波,這些資料都很重要,對我是一種實打實的鼓勵。

上一篇:Vue + Spring Boot 專案實戰(十三):使用 Shiro 實現使用者資訊加密與登入認證

下一篇:Vue + Spring Boot 專案實戰(十五):動態載入後臺選單

相關文章