遊手好閒的前端之SSO(單點登陸)實踐

引言

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

技術棧方面,後端採用 NodeJS 去實現,局部會話用 express-session 維護, session 的存儲使用了 redis ,因爲目前的項目都是先後端分離的,爲了更加契合當前的業務邏輯,把常規的跳轉至 passport 認證服務器登陸這部分改形成接口的方式,這樣使得這個 SSO 比較適合用在 SPA 中。前端

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

實現原理

SSO即Single Sign On,是指在多系統應用羣中登陸一個系統,即可在其餘全部系統中獲得受權而無需再次登陸。 SSO通常都須要一個獨立的認證中心(passport),子系統的登陸均得經過passport,子系統自己將不參與登陸操做,當一個系統成功登陸之後,passport將會頒發一個令牌給各個子系統,子系統能夠拿着令牌會獲取各自的受保護資源,爲了減小頻繁認證,各個子系統在被passport受權之後,會創建一個局部會話,在必定時間內能夠無需再次向passport發起認證。ios

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

上面這張圖很詳細地描述了一個SSO的請求資源的流程。可是這裏有一點地方不適合我當前的業務場景,那就是我並不但願在登陸的時候跳轉到認證中心,因此我把這個部分轉化成了接口的方式去實現,其餘的實現基本如圖一致。

具體實現

準備環境

首先須要作一些準備工做,爲了方便測試SSO,須要至少三個域名,這邊我直接在本地模擬。若是手頭有服務器域名的,這一步天然就能夠跳過了。git

構造本地域名(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

準備一份簡單的登陸頁面

頁面大概就長這個樣子,這裏分別要準備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攻擊。github

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去向各自的服務器去發起資源的請求。前面有提到,請求資源以前須要進行認證,認證成功之後將會生成局部會話,以後的請求均可以在必定時間內無需認證。redis

// 發起一個認證請求
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應該作一些調整便可直接使用。

還有諸多考慮不周的地方,但願各位大佬能夠給予些許指點。

相關文章
相關標籤/搜索