首先爲何是遊手好閒呢...由於咱們公司就我一個前端,不乖乖寫頁面寫什麼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
// 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
複製代碼
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; } } 複製代碼
// 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程序員
登陸所有向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)) } }) 複製代碼
登陸成功之後,咱們能夠嘗試去獲取受保護資源,因爲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來操心。數據庫
認證成功之後獲取具體的資源則由各個子系統各自執行。
認證這一環節主要是檢驗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)) } }) 複製代碼
當用戶主動退出某個子系統時,須要將該domain下的全部子系統都退出,因爲以前將session相關的存入了redis中,因此在註銷的時候須要將這些session所有清除,不然的話可能會致使子系統在必定時間內仍然能夠獲取資源的問題。這裏我交給了clearClientStore(token)
和deleteToken(token)
這兩個函數。
其實整個SSO流程走下來仍是比較清晰的,但在作以前感受至關棘手至關有難度(或許只是對我這個前端來講有難度),這期間也碰到了不少奇怪的問題,一方面是本身思路常常走歪的問題,另外一方面則是本身不夠熟練,摸石頭過河。期間碰到問題之後也看了諸如express-session和connect-redis的部分源碼實現才得以理解。
'sess:'
,因此從redis中獲取sid的時候必須得get prefix + sid
深入認識到有些時候苦苦不能解決一個問題的時候,那必定是以前的思路有問題,這時候必須得靜下心來從問題的根源找起,對於程序員來講尋找問題的根源的最有效辦法就是閱讀源碼了。
還在設計的過程當中考慮如何減小子系統的接入成本(僅須要進行認證一步操做),安全性方面的考慮(httpOnly,RealIP過濾,session有效期等),性能方面的考慮(局部會話和redis)
最後附上完整的示例代碼 懇請各位大佬給個Star吧,小弟在此跪謝了
,代碼裏把config文件夾ignore了,裏面只有一份數據庫配置項和加鹽參數而已。passport應該作一些調整便可直接使用。
還有諸多考慮不周的地方,但願各位大佬能夠給予些許指點。