登入標識
系統通常只有登入成功後才能訪問,而 http 是無狀態的。倘若直接請求需要登入
才可訪問的介面,假如後端反覆查詢資料庫,而且每個請求還得帶上使用者名稱和密碼,這都是不很好。
作為前端,我們聽過 cookie
(session) 和 token
,他們都是登入標識
,各有特色,本篇都將完整實現。
Tip:在上文(起步篇)基礎上進行
cookie 和 session
express-session
express-session —— 用於 Express 中使用 session,對於前端是無感知的,因為 cookie 會自動傳送給後端。
安裝 express-session
包:
PS E:\pjl-back-end> npm install express-session
added 6 packages, and audited 85 packages in 9s
2 packages are looking for funding
run `npm fund` for details
4 vulnerabilities (3 high, 1 critical)
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
程式碼
app.js
app.js 中註冊 session,以及設定攔截器。
$ git diff app.js
+// session
+var session = require('express-session')
var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
const UserRouter = require('./routes/UserRouter')
// 跨域程式碼省略...
// 使用跨域中介軟體
app.use(allowCors);
+// 註冊 session 中介軟體
+app.use(session({
+ // sessionId 的名字。The name of the session ID cookie to set in the response
+ name: 'pjl-system',
+ // 秘鑰
+ secret: 'pjl-system-demo',
+ cookie: {
+ maxAge: 1000*60, // 60秒過期
+ // true - 只有 https 才能訪問 cookie
+ secure: false
+ },
+ // true - 初始就給到 cookie。例如沒有登入,直接點選“獲取使用者列表”,也會給到 cookie
+ saveUninitialized: true,
+}))
// 放開靜態資源
app.use(express.static(path.join(__dirname, 'public')));
+// 請求攔截器
+app.use(function(req, res, next) {
+ // 如果是登入或登出,則放行
+ if(req.url.includes('login')){
+ next()
+ // 否則還會執行
+ return
+ }
+
+ // 登入 並且 session 有效
+ if(req.session.user){
+ // 只要來請求,就更新過期時間
+ req.session.date = Date.now()
+ next()
+ }else{
+ // session 失效,返回 401,通知“請重新登入”
+ res.status(401).json({code: '-1', msg: '請重新登入'})
+ }
+});
app.use('/', indexRouter);
Tip:更新過期時間重新設定 session,例如這裡的 req.session.date = Date.now()
。任意自定義屬性都可以,只要標識 session 有改變即可
UserRouter.js
增加3個介面:登入
、登出
、使用者列表
。
$ git diff routes/UserRouter.js
var express = require('express');
var router = express.Router();
const UserController = require('../controllers/UserController.js')
+// 登入
+router.post('/user/login', UserController.login);
+// 登出
+router.get('/user/loginout', UserController.loginOut);
+// 使用者列表
+router.get('/user/list', UserController.userList);
UserController.js
實現3個介面。登入後設定
session,登出後銷燬
session。
$ git diff controllers/UserController.js
const UserController = {
error: '使用者名稱密碼不匹配'
})
}else{
+ // 登入成功
+ /*
+ result[0] {
+ _id: new ObjectId("6441f499113fbc9501443c70"),
+ username: 'pjl',
+ password: '123456'
+ }
+ */
+ console.log('result[0]', result[0])
+ // 刪除密碼
+ delete result[0].password
+ // 設定 session。好比給 session 這個房子裡放點東西
+ req.session.user = result[0]
res.send({
code: '0',
error: ''
})
}
- }
+ },
+ // 登出
+ loginOut: async (req, res) => {
+ req.session.destroy(() => {
+ res.send({code: 0, msg: '登出成功'})
+ })
+ },
+ // 使用者列表
+ userList: async (req, res) => {
+ var result = await UserService.userList()
+ if(result.length === 0){
+ res.send({
+ code: '-1',
+ msg: '使用者列表獲取失敗'
+ })
+ return
+ }
+
+ res.send({
+ code: '0',
+ data: {
+ rows: result,
+ },
+ msg: '使用者列表獲取成功'
+ })
+ },
}
UserService.js
實現使用者列表資料庫的查詢。
$ git diff services/UserService.js
const UserService = {
login: async ({username, password}) => {
return UserModel.find({
username,
password
})
},
+// 使用者列表
+userList: async () => {
+ return UserModel.find()
+}
}
校驗
後端
啟動後端服務,允許前端訪問新增的三個介面
PS E:\pjl-back-end> npm run start
> pjl-back-end@0.0.0 start
> nodemon ./bin/www
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node ./bin/www`
express-session deprecated undefined resave option; provide resave option app.js:30:9
資料庫連線成功
Tip:筆者這裡的環境需要啟動資料庫,以及允許跨域請求。
前端
筆者直接使用 amis-editor
(amis 低程式碼編輯器,更多瞭解請看這裡)花費5分鐘繪製一個如下前端頁面:
Tip: 線上的 amis-editor 編輯器感覺有點慢,直接下載到本地啟動。筆者使用的是 chrome 109
。firefox 報錯(不管)
測試
現在瀏覽器開發工具檢視cookie是空的
筆者故意輸錯密碼
,點選登入
,返回{"code":"-1","error":"使用者名稱密碼不匹配"}
。雖然登入失敗,但 cookie 也會有值(saveUninitialized: true,
的作用,如果為 false 則需要登入成功後 cookie 才有值)。
// Genaral
Request URL: http://localhost:3000/user/login
Request Method: POST
Status Code: 200 OK
Remote Address: [::1]:3000
Referrer Policy: strict-origin-when-cross-origin
// Response
{"code":"-1","error":"使用者名稱密碼不匹配"}
使用者列表
需要登入後才能獲取,現在沒有登入,點選獲取使用者列表
,返回 401。前端可以根據這個返回做路由跳轉至登入頁。
輸入正確的密碼,登入成功
再次點選獲取使用者列表
,返回使用者列表資訊:
// Genaral
Request URL: http://localhost:3000/user/list
Request Method: GET
Status Code: 200 OK
Remote Address: [::1]:3000
Referrer Policy: strict-origin-when-cross-origin
// Response Headers
Set-Cookie: pjl-system=s%3AuMMniH85GEC5T1c5vW5BXH0mlDlt91RT.AL7IEIrnkw5mh%2FFjARknIkWziJNWSjgSe37u5LtSLek; Path=/; Expires=Tue, 25 Apr 2023 06:27:25 GMT; HttpOnly
// Request Headers
// 自動發出 cookie,容易引起安全問題
Cookie: pjl-system=s%3AuMMniH85GEC5T1c5vW5BXH0mlDlt91RT.AL7IEIrnkw5mh%2FFjARknIkWziJNWSjgSe37u5LtSLek
// Response
{"code":"0","data":{"rows":[{"_id":"6441f499113fbc9501443c70","username":"pjl","password":"123456"}]},"msg":"使用者列表獲取成功"}
Tip: 這裡也說明 cookie 會自動
發出去
而且過期時間也從 27:03
推遲到 27:25
,說明只要使用者操作了,session 的過期時間就會更新。比如筆者這裡設定 1 分鐘,只要在這個時間內不停的與後端互動,session 就不會過期。
Tip:限制訪問 Cookie 有 Secure
屬性和 HttpOnly
屬性。這裡的是 HttpOnly,所以透過瀏覽器控制檯 document.cookie
返回空。
點選登出
,再次獲取使用者列表,則報 401。
connect-mongo
目前有個問題
:登入後,能請求到使用者列表,只要儲存後端,服務就會重啟,再次請求使用者列表就報 401,因為現在 session 存在記憶體
中,重啟服務記憶體中的資料就清空了。
...
[nodemon] restarting due to changes...
[nodemon] starting `node ./bin/www`
express-session deprecated undefined resave option; provide resave option app.js:32:9
資料庫連線成功
我們可以藉助 connect-mongo
將 session 存入mongo 資料庫中。
用法很簡單,首先安裝包,然後配置如下即可:
PS E:\pjl-back-end> npm i connect-mongo
added 7 packages, and audited 92 packages in 12s
2 packages are looking for funding
run `npm fund` for details
4 vulnerabilities (3 high, 1 critical)
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
$ git diff app.js
// session
var session = require('express-session')
+// 將session 存入 mongo
+var MongoStore = require('connect-mongo')
app.use(session({
saveUninitialized: true,
+ store: MongoStore.create({
+ mongoUrl: 'mongodb://192.168.1.123:27017/pjl_session_db',
+ // 預設情況下,connect-mongo使用MongoDB的TTL收集功能(2.2+)讓mongodb自動刪除過期的會話
+ ttl: 1000 * 60
+ })
}))
重啟後端服務,進入 mongo shell 發現 pjl_session_db
資料庫被自動建立:
> show dbs
admin 0.000GB
config 0.000GB
local 0.000GB
pjl_db 0.000GB
pjl_session_db 0.000GB
目前裡面有一張 sessions 的空表:
> use pjl_session_db
switched to db pjl_session_db
> db.getCollectionNames()
[ "sessions" ]
> db.session.find()
>
Tip: 為方便測試,筆者將過期時間(和 ttl)設定成 10 秒。並設定 saveUninitialized:false
輸入正確的使用者名稱和密碼,登入成功,儲存後端讓服務重啟,直接點選獲取使用者資訊
,使用者資訊正常返回,說明session不在儲存在記憶體中。繼續不停的點選獲取使用者資訊
時,透過瀏覽器開發模式發現 cookie 過期時間也在不停的更新。
此刻 sessions 表中有一條資料:
> db.sessions.find()
{ "_id" : "Y0rWLhjaDSB6eDwjIcsoF91AmJDYm9mn", "expires" : ISODate("2023-04-25T08:13:52.597Z"), "session" : "{\"cookie\":{\"originalMaxAge\":10000,\"expires\":\"2023-04-25T08:13:52.597Z\",\"secure\":false,\"httpOnly\":true,\"path\":\"/\"},\"user\":{\"_id\":\"6441f499113fbc9501443c70\",\"username\":\"pjl\",\"password\":\"123456\"},\"date\":1682410422595}" }
// 點選登出後執行
> db.sessions.find()
>
點選 登出
後發現 sessions 又為空了。
再次登入後,等待過期,雖然筆者設定的 ttl 是 10 秒,但是筆者在 34 秒查詢還有該條資料,38 秒時清空了 —— 說明 資料庫 ttl 自動清除
session 生效,但時間不一定是我們設定的。暫未深入研究。
JSON Web Token
在react 高效高質量搭建後臺系統 系列 —— 登入中我們使用了 Token。
Token 使用的大致流程:輸入使用者名稱、密碼,登入成功後,後端返回資料中包含 token(即後端分配給使用者的一個登入標識
),前端將其儲存在 localStorage 中,後續前端所有的請求都將會帶上這個標識(token),後端接受請求後,驗證 token 是否有效,有效則放行請求,如果無效(比如 token 過期、token 偽造),則返回 401 告訴前端“會話過期,請重新登入”。
Token 是個什麼東西,為什麼可以用作登入標識
? 請看 jsonwebtoken。
jsonwebtoken
安裝 jsonwebtoken(JSON Web Tokens 的實現):
PS E:\pjl-back-end> npm i jsonwebtoken
added 10 packages, and audited 102 packages in 12s
2 packages are looking for funding
run `npm fund` for details
4 vulnerabilities (3 high, 1 critical)
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
在 app.js 末尾新增如下程式碼對 jsonwebtoken 進行初步體驗:
// test token
var jwt = require('jsonwebtoken')
// 秘鑰。隨便寫
var private_key = 'pjl-system-private-key'
// 資料。例如以後的系統登入使用者名稱資料
var payload = {
id: 1,
username: 'pjl'
}
// 過期時間
var expire = '10s'
var token = jwt.sign(payload, private_key, { expiresIn: expire})
console.log('token', token)
// 驗證 token
var decoded = jwt.verify(token, private_key)
console.log('decoded', decoded)
// 過期後驗證
setTimeout(() => {
var decoded = jwt.verify(token, private_key)
console.log('過期後解碼decoded', decoded)
}, 11 * 1000)
啟動服務,控制檯輸出:
[nodemon] restarting due to changes...
[nodemon] starting `node ./bin/www`
// token
token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJwamwiLCJpYXQiOjE2ODI0ODg4NzQsImV4cCI6MTY4MjQ4ODg4NH0.mk91VodC-nuUqjUwu2idqrgQDyy_sjASXSmH6g3Go3I
// decoded
decoded { id: 1, username: 'pjl', iat: 1682488874, exp: 1682488884 }
資料庫連線成功
// 11秒後再次驗證 token 報錯
E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:40
if (err) throw err;
^
TokenExpiredError: jwt expired
at E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:190:21
at getSecret (E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:97:14)
at Object.module.exports [as verify] (E:\pjl-back-end\node_modules\jsonwebtoken\verify.js:101:10)
at Timeout._onTimeout (E:\pjl-back-end\app.js:131:21)
at listOnTimeout (node:internal/timers:559:17)
at processTimers (node:internal/timers:502:7) {
expiredAt: 2023-04-26T06:01:24.000Z
}
[nodemon] app crashed - waiting for file changes before starting...
我們將 token 放入 jwt.io 能看到解碼後的資訊:
jwt 通常是 xxx.yyy.zzz
的字串。包含3部分:Header
、Payload
、Signature
(簽名)。
驗證時,取出 token 的 Header
和Payload
,並配合秘鑰生成簽名
,在對比 token 中的Signature
,如果相同則說明驗證透過。
Tip:由於驗證失敗時會報錯,所以下文需要對 jwt 進行封裝
程式碼
jwt.js
對 token 封裝,匯出生成 token 和校驗 token 的方法。
// jwt.js
// test token
const jwt = require('jsonwebtoken')
// 秘鑰。隨便寫
const private_key = 'pjl-system-private-key'
const JWT = {
// 生成 token
generate(payload, expire){
return jwt.sign(payload, private_key, { expiresIn: expire})
},
// 驗證 token
verify(token){
// 驗證失敗會報錯,所以需要 try...catch
try{
return jwt.verify(token, private_key)
}catch(e){
return false
}
}
}
module.exports = JWT
UserController.js
登入時將 token 設定給 request 的頭部,客戶端將 token 儲存,下次請求則再次攜帶 X-Token:
$ git diff controllers/UserController.js
const UserModel = require('../models/UserModel')
+const JWT = require('../libs/jwt')
const UserController = {
login: async (req, res) => {
// req.body - 例如 {"username":"pjl","password":"123456"}
const UserController = {
+ // payload 必須是 plain object,所以不能直接寫 result[0]
+ const payload = {
+ _id: result[0]._id,
+ username: result[0].username
+ }
+ let token = JWT.generate(payload, '1h')
+ res.header('X-Token', token)
res.send({
code: '0',
error: ''
app.js
如果是 login 的請求則放行。其他請求如果沒有 token 則返回 401,有 token 則校驗是否有效,有效則生成新的 token。
$ git diff app.js
+var JWT = require('./libs/jwt')
// 跨域參考:https://blog.csdn.net/gdutRex/article/details/103636581
var allowCors = function (req, res, next) {
res.header('Access-Control-Allow-Origin', 'http://localhost');
- res.header('Access-Control-Allow-Headers', 'Content-Type,lang,sfopenreferer ');
+ // 增加 X-Token,否則報錯:
+ res.header('Access-Control-Allow-Headers', 'Content-Type,lang,sfopenreferer,X-Token');
res.header('Access-Control-Allow-Credentials', 'true');
- next();
+ // 預檢。參考:https://troyyang.com/2017/06/06/Express_Cors_Preflight_Request/
+ if (req.method == "OPTIONS") {
+ res.send(200);
+ }
+ else {
+ next();
+ }
};
+app.use(function(req, res, next) {
+ // 如果是登入則放行
+ if(req.url.includes('login')){
+ next()
+ // 否則還會執行
+ return
+ }
+
+ // console.log('req.headers', req.headers)
+ // X-Token 接收的是小寫 x-token
+ const token = req.headers[('X-Token').toLowerCase()]
+ const payload = JWT.verify(token)
+ console.log('token', token)
+ // // 存在 token 並校驗成功則透過,否則401
+ if(token && payload){
+ const newPayload = {
+ _id: payload._id,
+ username: payload.username
+ }
+ console.log('newPayload', newPayload)
+ // 直接用 payload 瀏覽器控制檯報錯:Bad "options.expiresIn" option the payload already has an "exp" property.
+ const newToken = JWT.generate(newPayload, '10s')
+ // console.log('newToken', newToken)
+ res.header('X-Token', newToken)
+ next()
+ }else{
+ res.status(401).json({code: '-1', msg: '請重新登入'})
+ }
+});
Tip:其中筆者環境涉及跨域,透過新增 X-Token
和 OPTIONS
的程式碼用於解決如下兩個問題:
// 已被 CORS 策略阻止:預檢響應中的訪問控制允許標頭不允許請求標頭欄位 x 令牌
index.html#/edit/2:1 Access to XMLHttpRequest at 'http://localhost:3000/user/list' from origin 'http://localhost' has been blocked by CORS policy: Request header field x-token is not allowed by Access-Control-Allow-Headers in preflight response.
// 已被 CORS 策略阻止:對預檢請求的響應未透過訪問控制檢查:它沒有 HTTP 正常狀態。
index.html#/edit/2:1 Access to XMLHttpRequest at 'http://localhost:3000/user/list' from origin 'http://localhost' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
測試
筆者仍舊在 amis-editor 中進行,首先登陸,請求返回 X-Token,給獲取使用者列表
增加 X-Token,服務端正常接收,並設定新的 X-Token 給前端
"api": {
"url": "http://localhost:3000/user/list",
"method": "get",
"headers": {
"X-Token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NDQxZjQ5OTExM2ZiYzk1MDE0NDNjNzAiLCJ1c2VybmFtZSI6InBqbCIsImlhdCI6MTY4MjQ5NDM2MCwiZXhwIjoxNjgyNDk3OTYwfQ.fuA9FiqnE15i6YEicGZVdzhzIkNpZhkPzGvWVQZ7qdY"
}
}
// 第二次請求“使用者列表”,返回新的 Token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NDQxZjQ5OTExM2ZiYzk1MDE0NDNjNzAiLCJ1c2VybmFtZSI6InBqbCIsImlhdCI6MTY4MjQ5NTUwNiwiZXhwIjoxNjgyNDk1NTE2fQ.mtg9l9-hh33HHFX8PwKRrerUs61JxHbdRY3jwLTZVbI
Session Vs Token
session 的流程:使用者登入,session 在服務端生成一個房間(房間放在資料庫中),並在房間中放點東西,接著把房間鑰匙(即 session Id)設定回 cookie,後續客戶端的所有請求都會自動
帶上這個 cookie(鑰匙),服務端則會根據鑰匙去找房間,能找到房間則請求透過,否則告訴前端重新登入
token 的流程:使用者登入,伺服器生成 token 並返回,前端將 token 存入 localStorage,後續所有請求手動將 token 傳回服務端,服務端透過秘鑰驗證 token,驗證成功則請求透過,否則告訴前端重新登入
Session 特點
- 上文透過 express-session 實現登入授權,對前端來說是無感知的
- cookie 會隨著 http 自動傳送,容易引起安全問題
- Session 存在資料庫,如果使用者數過多,服務端開銷就會大
- 後端如果有叢集,可能就得涉及多個 session 之間的同步。如果將多個 session 資料庫提取出一個公共服務,存在一個機器中,假如該服務當機,所有使用者得重新登入
Token 特點
- 佔頻寬,每次請求都帶上 Token
- token 不會自動傳送,得手動傳送
- 無法在服務端登出(可配合服務端實現登出 token)
登入標識 vs 許可權
本篇的 token 和 session 僅僅是登入標識,而非許可權
,筆者之前寫過 前端許可權,而前端許可權是不靠譜的,所以後端許可權通常更重要。
比如直接透過傳送一個請求給後端,後端就得查詢這個使用者(token、cookie都可以獲取使用者名稱等資訊)的角色、許可權,所以每個請求過來,都需要查詢資料庫。
Tip:更多請自行查閱 RABC
許可權