如何自己實現一個健壯的 SSO 單點登入系統

邢闖洋發表於2021-07-24

因公司後臺按照業務劃分,不同的業務需要有不同的後臺,越來越多的時候每次登入後臺都要重新輸入賬號密碼實在是不方便,所以需要實現一個 SSO 單點登入,網上翻閱了一些 SSO 的實現方案,有如下幾個實現方案:

  • 基於父級域名實現跨域 Cookie
  • 基於 LocalStorage 跨域
  • 基於自己搭建認證中心

本篇文章選用了搭建認證中心該方案,該實現效果和其他 SSO 單點登入一樣,有如下特點:

  • 跨域名的單點登入(一個站點登入即所有站點都登入)

  • 跨域名的單點退出(一個站點退出即所有站點都退出)

  • 實時的賬戶資訊同步(當 A 站點的 A 賬戶切換了 B 賬戶重新登入後,訪問其他站點也會切換至 B 賬戶)

    涉及技術點

  • Redis(採用 Redis 儲存使用者的 Token 實現多站點統一 Token)

  • JWT(採用 JWT 實現使用者的 Token 加密)

後臺實現

passport 後臺

passport 後臺登入需要實現的方法

  • login:賬號登入(供使用者登入使用)
  • authTokenLogin:授權碼登入(供其他站點自動登入使用)
  • getAuthToken:獲取授權 token
  • logout:退出登入
  • Middleware 中介軟體校驗

賬號登入

// TODO:: 基礎邏輯,驗證賬號密碼業務邏輯...

// 呼叫 jwt 生成token
$token = JWT::enToken($admin->id);

// 將使用者token存入 redis
Redis::set("adminUserToken:{$admin->id}", $token);

return $this->succeed([
    'token' => $token
]);

賬號登入的通用邏輯為使用 jwt 生成 token,然後將使用者 token 存入 Redis。

授權碼登入

// 獲取到前端傳來的授權token,並解密出使用者ID
$adminId = CommonSupport::authcode($request->input("authToken"));

// TODO:: 驗證業務邏輯...

// 獲取使用者當前的登入token
$redisToken = Redis::get("adminUserToken:{$adminId}");

return $this->succeed([
    'token' => $redisToken
]);

授權碼登入的使用場景為當訪問其他站點時,若本地 token 已過期或本地沒有 token,則重定向至 passport 後臺,passport 後臺判斷本地為已登入狀態時會向介面索要 authToken,並帶上 authToken 重定向至其他站點,其他站點獲取到 url 上有 authToken 時,則會用 authToken 進行登入,然後下發使用者當前的 token,並儲存,完成了登入流程。

獲取授權 token

// 獲取當前已登入的使用者ID
$adminId = Context::get("currentAdmin")['id'];

// 加密獲取授權Token
$authToken = CommonSupport::authcode($adminId, "ENCODE");

return $this->succeed([
    "authToken" => $authToken
]);

退出登入

// 獲取當前已登入的使用者ID
$adminId = Context::get("currentAdmin")['id'];

// 將該使用者 Token 從 redis 中刪除
Redis::del("adminUserToken:{$adminId}");

return $this->succeed();

中介軟體校驗

public function checkToken(string $token)
{
    // 解密 JWT token,驗證 token 是否有效,若解密失敗則報錯
    $jwt = JWT::deToken($token);
    if (!$jwt) {
        throw new ApiException(10001, "使用者驗證失敗");
    }
    $userId = (int)$jwt->data;

    // 從 redis 中獲取使用者token
    $redisToken = Redis::get("adminUserToken:{$userId}");

    // 如果使用者token不存在redis或者和redis中的不相等,則報錯
    if ($redisToken != $token) {
        throw new ApiException(10001, "使用者驗證失敗");
    }

    // 判斷管理員是否存在
    $admin = Admin::find($userId);
    if (!$admin) {
        throw new ApiException(10001, "使用者驗證失敗");
    }

    return $this->succeed($admin->toArray());
}

業務後臺

業務後臺的實現就變得簡單,只需要在中介軟體中呼叫 passport 後臺的 checkToken 方法,具體實現可使用 Http 方式請求,或者 Rpc 呼叫。

前端實現

前端技術採用的是 Vue2.0,使用 vue-element-admin實現

passport 前端

permission.js 檔案

permission.js 檔案在每次重新整理頁面時都會進入該頁面,在該頁面通過獲取 url 上特定的引數來完成特定的動作

router.beforeEach(async(to, from, next) => {
  // start progress bar
  NProgress.start()

  // 設定頁面標題
  document.title = getPageTitle(to.meta.title)

  // 全域性獲取url上的請求引數
  const query = to.query

  // 獲取本地token
  const hasToken = getToken()

  // 如果登入了
  if (hasToken) {
    // 如果有場景值
    if (query.scene) {
      // 如果場景值是退出登入,並且有重定向地址,則跳轉到登入介面並銷燬本地token
      if (query.scene === 'logout' && query.redirectUri) {
        const redirectUri = encodeURIComponent(query.redirectUri)
        await store.dispatch('user/logout')
        next(`/login?redirectUri=${redirectUri}`)
        NProgress.done()
        return
      }
    }

    // 如果有重定向地址
    if (query.redirectUri) {
      // 則獲取授權token
      const authToken = await getAuthToken()
      const redirectUri = query.redirectUri
      // 跳轉到重定向地址,把授權token帶過去
      window.location = redirectUri + '?token=' + authToken.data.authToken
      NProgress.done()
      return
    }

    // 如果沒登入
  } else {

    // 如果有場景值
    if (query.scene) {
      // 如果場景值是退出登入,並且有重定向地址,則跳轉到登入介面
      if (query.scene === 'logout' && query.redirectUri) {
        next(`/login?redirectUri=${encodeURIComponent(query.redirectUri)}`)
        NProgress.done()
        return
      }
    }

    // 如果有企業微信回撥回來的code
    if (query.code) {
      // 呼叫介面使用微信授權碼登入
      const tokenData = await workWechatCodeLogin({
        code: query.code
      })

      // 將 token 存入本地
      const token = tokenData.data.token
      setToken(token)

      // 如果有重定向地址
      if (query.state) {
        window.location.href = process.env.VUE_APP_CURRENT_URL +
          '/home?redirectUri=' + encodeURIComponent(Base64.decode(query.state))
      } else {
        window.location.href = process.env.VUE_APP_CURRENT_URL
      }
    }

    if (whiteList.indexOf(to.path) !== -1) {
      // in the free login whitelist, go directly
      next()
    } else {
      // other pages that do not have permission to access are redirected to the login page.

      if (query.redirectUri) {
        next(`/login?redirectUri=${query.redirectUri}`)
      } else {
        next(`/login?redirect=${to.path}`)
      }
      NProgress.done()
    }
  }
})

request.js

該檔案為網路請求基礎檔案,該檔案中需要修改當介面返回登入失效的時候,直接跳轉至登入頁。

  response => {
    const res = response.data

    // if the custom code is not 200, it is judged as an error.
    if (res.code !== 200) {
      // 如果介面返回 10001 代表登入失效,則重定向至登入頁
      if (res.code === 10001) {
        // 先刪除token
        removeToken()
        // 然後跳轉到登入頁
        window.location = process.env.VUE_APP_CURRENT_URL + '/login'
        return
      }

      Message({
        message: res.msg || 'Error',
        type: 'error',
        duration: 5 * 1000
      })

      return Promise.reject(new Error(res.msg || 'Error'))
    } else {
      return res
    }
  },

業務前端

permission.js

    //TODO:: 先判斷如果是沒登入的話
    // 判斷是否有從 passport 後臺重定向回來並帶著token
    if (query.token) {

      // 根據授權token去請求登入token
      const tokenData = await authTokenLogin({
        authToken: query.token,
        platform_id: process.env.VUE_APP_CURRENT_PLATFORM_ID
      })
      // 將token存入本地並重新整理當前頁面
      const token = tokenData.data.token
      setToken(token)
      window.location = process.env.VUE_APP_CURRENT_HOME_URL
      location.reload()

      // 如果沒有 授權token引數
    } else {

      // 則跳轉到 passport 登入頁面並帶著當前後臺的地址作為 redirectUri 引數
      // other pages that do not have permission to access are redirected to the login page.
      const redirectUri = encodeURIComponent(process.env.VUE_APP_CURRENT_HOME_URL)
      window.location = process.env.VUE_APP_PASSPORT_WEB_LOGIN_URL + '?redirectUri=' + redirectUri
      NProgress.done()
    }

request.js

    if (res.code !== 200) {
      // 如果介面返回 10001 代表登入失效,則重定向至登入頁
      if (res.code === 10001) {
        // 先刪除token
        removeToken()
        // 然後帶著當前地址作為 redirectUri 跳轉到 SSO 登入頁
        const redirectUri = encodeURIComponent(process.env.VUE_APP_CURRENT_HOME_URL)
        window.location = process.env.VUE_APP_PASSPORT_WEB_LOGIN_URL + '?scene=logout&redirectUri=' + redirectUri
      }

      Message({
        message: res.msg || 'Error',
        type: 'error',
        duration: 5 * 1000
      })

      return Promise.reject(new Error(res.msg || 'Error'))
    }
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章