首先爲何是遊手好閒呢...由於咱們公司就我一個前端,不乖乖寫頁面寫什麼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應該作一些調整便可直接使用。
還有諸多考慮不周的地方,但願各位大佬能夠給予些許指點。