不務正業的前端之SSO(單點登入)實踐

一橫發表於2018-07-21

引言

首先為什麼是不務正業呢...因為我們公司就我一個前端,不乖乖寫頁面寫什麼SSO。我之所以會想到去寫SSO單點登入呢,一是發現公司的登入這塊特別的亂,每個系統都是獨立的登入,而某些業務都是有所交集的,既然一個是a.xxx.com一個是b.xxx.com,那為什麼不把登入統一一下呢...正巧趕上我們後端大哥在攻堅一個技術難關,於是乎我在等介面的間隙就著手寫了一下單點登入。

技術棧方面,後端採用 NodeJS 去實現,區域性會話用 express-session 維護, session 的儲存使用了 redis ,由於目前的專案都是前後端分離的,為了更加契合當前的業務邏輯,把常規的跳轉至 passport 認證伺服器登入這部分改造成介面的方式,這樣使得這個 SSO 比較適合用在 SPA 中。

下面將具體闡述實現以及總結一些需要注意的點,願在下的拙見對大家能有所幫助。

實現原理

SSO即Single Sign On,是指在多系統應用群中登入一個系統,便可在其他所有系統中得到授權而無需再次登入。 SSO一般都需要一個獨立的認證中心(passport),子系統的登入均得通過passport,子系統本身將不參與登入操作,當一個系統成功登入以後,passport將會頒發一個令牌給各個子系統,子系統可以拿著令牌會獲取各自的受保護資源,為了減少頻繁認證,各個子系統在被passport授權以後,會建立一個區域性會話,在一定時間內可以無需再次向passport發起認證。

如圖所示,是一個比較常見的SSO實現,圖片取自

不務正業的前端之SSO(單點登入)實踐
上面這張圖很詳細地描述了一個SSO的請求資源的流程。但是這裡有一點地方不適合我當前的業務場景,那就是我並不希望在登入的時候跳轉到認證中心,所以我把這個部分轉化成了介面的方式去實現,其他的實現基本如圖一致。

具體實現

準備環境

首先需要做一些準備工作,為了方便測試SSO,需要至少三個域名,這邊我直接在本地模擬。如果手頭有伺服器域名的,這一步自然就可以跳過了。

構造本地域名(Mac)

1. 配置hosts檔案

// MacOS
sudo vim /etc/hosts
// 新增以下三行
127.0.0.1   testssoa.xxx.com
127.0.0.1   testssob.xxx.com
127.0.0.1   passport.xxx.com
複製程式碼

2. 新增nginx反向代理配置

  1. 先安裝nginx
  2. 新增對應站點的配置
vim /usr/local/etc/nginx/nginx.conf

// 新增以下3個代理
server {
  listen 1280;
  server_name passport.xxx.com;

  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://127.0.0.1:11000;
  }
}

server {
  listen 1280;
  server_name testssoa.xxx.com;

  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://127.0.0.1:11001;
  }
}

server {
  listen 1280;
  server_name testssob.xxx.com;

  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://127.0.0.1:11002;
  }
}
複製程式碼
  1. nginx -t 檢測配置是否有效
  2. nginx -s reload 重啟nginx

準備一份簡單的登入頁面

不務正業的前端之SSO(單點登入)實踐
頁面大概就長這個樣子,這裡分別要準備testssoa和testssob兩個域名,為了公用一個頁面這裡我採用的方案是直接通過node將該頁面render回來的方式,並且需要根據上面nginx配置的埠號啟動埠指定為11001和11002的服務。

// package.json
"scripts": {
  "start": "babel-node passport.js",
  "starta": "cross-env NODE_ENV=ssoa babel-node index.js",
  "startb": "cross-env NODE_ENV=ssob babel-node index.js"
}

// index.js
import express from 'express' // import需要babel支援
const app = express()
const mapPort = {
  'ssoa': 11001,
  'ssob': 11002
}
const port = mapPort[process.env.NODE_ENV]
if (port) {
  console.log('listen port: ', port)
  app.listen(port)
}
複製程式碼

簡單的配置一下,這樣可以直接通過npm run starta和npm run startb來起來兩個server

具體步驟

1. 使用者登入

登入全部向paspport發起,這裡採用了jwt來維護使用者的登入態(考慮到app端),登入成功以後會把token儲存到redis中,並且將token寫入domain為xxx.com這個頂級域名中,這樣的話不同的子系統都可獲得token,同時設定httpOnly可以預防一部分xss攻擊。

app.post('/login', async (req, res, next) => {
  // 登入成功則給當前domain下的cookie設定token
  const { username, password } = req.body

  // 通過 username 跟 password 取出資料庫中的使用者
  try {
    const user = await authUser(username, password)
    const lastToken = user.token
    // 此處生成token,此處使用jwt
    const newToken = jwt.sign(
      { username, id: user.id },
      tokenConfig.secret,
      { expiresIn: tokenConfig.expiresIn }
    )
    // 儲存token到redis中
    await storeToken(newToken)

    // 生成新的token以後需要清除子系統的session
    if (lastToken) {
      await clearClientStore(lastToken)
      await deleteToken(lastToken)
    }

    res.setHeader(
      'Set-Cookie',
      `token=${newToken};domain=xxx.com;max-age=${tokenConfig.expiresIn};httpOnly`)

    return res.json({
      code: 0,
      msg: 'success'
    })
  } catch (err) {
    next(new Error(err))
  }
})
複製程式碼

2. 使用者訪問受保護資源(認證過程)

登入成功以後,我們可以嘗試去獲取受保護資源,由於passport對domain為xxx.com的域名設定了cookie,所以無論是a.xxx.com還是b.xxx.com均可使用該cookie去向各自的伺服器去發起資源的請求。前面有提到,請求資源之前需要進行認證,認證成功以後將會生成區域性會話,之後的請求都可以在一定時間內無需認證。

// 發起一個認證請求
const authenticate = async (req) => {
  const cookies = splitCookies(req.headers.cookie)
  // 判斷是否含有token,如沒有token,則返回失敗分支
  const token = cookies['token']
  if (!token) {
    throw new Error('token is required.')
  }

  const sid = cookies['sid']

  // 如果獲取到user,則說明該使用者已經登入
  if (req.session.user) {
    return req.session.user
  }

  // 向passport伺服器發起一個認證請求
  try {
    // 這裡的sid應該是存在redis裡的key
    let response = await axiosInstance.post('/authenticate', {
      token,
      sid: defaultPrefix + req.sessionID,
      name: 'xxxx' // 可以用來區分具體的子系統
    })
    if (response.data.code !== 0) {
      throw new Error(response.data.msg)
    }
    // 認證成功則建立區域性會話,並將使用者標識儲存起來,比如這裡可以是一個uid,或者也可以是token
    req.session.user = response.data.data
    req.session.save()

    return response.data
  } catch (err) {
    throw err
  }
}
複製程式碼

對於需要接入SSO的子系統來說,真正需要做的事情就只有發起認證這一件事情,所以對於子系統本身來說,接入成本是很低的。即便不同語言的子系統實現的方式會有所差別,但是也沒什麼關係,這裡最核心的一件事情就是向passport發起認證,只需要按照約定把認證所需要的引數傳遞過去即可,剩下的事情都應該交給passport來操心。

認證成功以後獲取具體的資源則由各個子系統各自執行。

3. 認證環節(passport)

認證這一環節主要是檢驗token的有效性,一是檢驗該token是否存在於redis之中,二是校驗該token是否還有效,是否過期,並且解析出其中的使用者資訊,校驗成功以後需要將子系統註冊一下(存入redis,以token為key),方便後續登出。這裡還加了一個小判斷,就是判斷x-real-ip的,可以防範一定程度的偽造。

app.post('/authenticate', async (req, res, next) => {
  const { token, sid, name } = req.body
  try {
    // 檢查請求的真實IP是否為授權系統
    // nginx會將真實IP傳過來,偽造x-forward-for是無效的
    if (!checkSecurityIP(req.headers['x-real-ip'])) {
      throw new Error('ip is invalid')
    }
    // 判斷token是否還存在於redis中並驗證token是否有效, 取得使用者名稱和使用者id
    const tokenExists = await redisClient.existsAsync(token)
    if (!tokenExists) {
      throw new Error('token is invalid')
    }
    const { username, id } = await jwt.verify(token, tokenConfig.secret)
    // 校驗成功註冊子系統
    register(token, sid, name)
    return res.json({
      code: 0,
      msg: 'success',
      data: { username, id }
    })
  } catch (err) {
    // 對於token過期也應該執行一次clear操作
    next(new Error(err))
  }
})
複製程式碼

4. 登出環節

當使用者主動退出某個子系統時,需要將該domain下的所有子系統都退出,由於之前將session相關的存入了redis中,所以在登出的時候需要將這些session全部清除,否則的話可能會導致子系統在一定時間內仍然可以獲取資源的問題。這裡我交給了clearClientStore(token)deleteToken(token)這兩個函式。

問題思考與總結

其實整個SSO流程走下來還是比較清晰的,但在做之前感覺相當棘手相當有難度(或許只是對我這個前端來說有難度),這期間也碰到了很多奇怪的問題,一方面是自己思路經常走歪的問題,另一方面則是自己不夠熟練,摸石頭過河。期間碰到問題以後也看了諸如express-session和connect-redis的部分原始碼實現才得以理解。

遇到的問題及解決

  1. 使用express-session的時候一直在用regenerate去重新生成session,一直納悶自己的session玩什麼沒有生成,後來在某個大佬的指點下靜下心來看了原始碼發現,有些事情中介軟體已經幫忙做好了,對於session的操作我只需要做最簡單的set和get即可。
  2. redis一直讀取不到session的key值問題,這個問題在看了connect-redis的原始碼發現,它會預設給sid加一個一個prefix字首,預設為'sess:',所以從redis中獲取sid的時候必須得get prefix + sid

深刻認識到有些時候苦苦不能解決一個問題的時候,那一定是之前的思路有問題,這時候必須得靜下心來從問題的根源找起,對於程式設計師來說尋找問題的根源的最有效辦法就是閱讀原始碼了。

還在設計的過程中考慮如何減少子系統的接入成本(僅需要進行認證一步操作),安全性方面的考慮(httpOnly,RealIP過濾,session有效期等),效能方面的考慮(區域性會話和redis)

最後附上完整的示例程式碼 懇請各位大佬給個Star吧,小弟在此跪謝了,程式碼裡把config資料夾ignore了,裡面只有一份資料庫配置項和加鹽引數而已。passport應該做一些調整即可直接使用。

還有諸多考慮不周的地方,希望各位大佬可以給予些許指點。

相關文章