前面的話
實現使用者登入認證的方式常見的有兩種:一種是基於 cookie 的認證,另外一種是基於 token 的認證 。本文以基於cookie的認證為參照,詳細介紹JWT標準,並實現基於該標籤的使用者認證介面
cookie認證
傳統的基於 cookie 的認證方式基本有下面幾個步驟:
1、使用者輸入使用者名稱和密碼,傳送給伺服器
2、伺服器驗證使用者名稱和密碼,正確的話就建立一個會話( session ),同時會把這個會話的 ID 儲存到客戶端瀏覽器中,因為儲存的地方是瀏覽器的 cookie ,所以這種認證方式叫做基於 cookie 的認證方式
3、後續的請求中,瀏覽器會傳送會話 ID 到伺服器,伺服器上如果能找到對應 ID 的會話,那麼伺服器就會返回需要的資料給瀏覽器
4、當使用者退出登入,會話會同時在客戶端和伺服器端被銷燬
這種認證方式的不足之處有兩點
1、伺服器端要為每個使用者保留 session 資訊,連線使用者多了,伺服器記憶體壓力巨大
2、適合單一域名,不適合第三方請求
cookie認證的後端典型程式碼如下所示
const express = require('express'); const app = express(); const bodyParser = require('body-parser'); app.use(bodyParser.urlencoded({ extended: false })); const session = require('express-session') const pug = require('pug'); app.set('view engine', 'pug'); app.use(session({ secret: 'keyboard cat', resave: false, saveUninitialized: true })) app.get('/', function(req, res){ let currentUser = req.session.username; res.render('index', {currentUser}); }) app.get('/login', function(req, res){ res.sendFile('login.html', {root: 'public'}); }) app.post('/login', function(req, res){ let username = req.body.username; req.session.username = username; res.redirect('/'); }) app.get('/logout', function(req, res){ req.session.destroy(); res.redirect('/'); }) app.listen(3006, function(){ console.log('running on port 3006...'); })
token認證
下面來介紹token認證。詳細認證過程如下
1、使用者輸入使用者名稱密碼,傳送給伺服器
2、伺服器驗證使用者名稱和密碼,正確的話就返回一個簽名過的 token( token 可以認為就是個長長的字串),客戶端瀏覽器拿到這個 token
3、後續每次請求中,瀏覽器會把 token 作為 http header 傳送給伺服器,伺服器可以驗證一下簽名是否有效,如果有效那麼認證就成功了,可以返回客戶端需要的資料
4、一旦使用者退出登入,只需要客戶端銷燬一下 token 即可,伺服器端不需要有任何操作
這種方式的特點就是客戶端的 token 中自己保留有大量資訊,伺服器沒有儲存這些資訊,而只負責驗證,不必進行資料庫查詢,執行效率大大提高
JWT
上面介紹的token-based 認證過程是通過 JWT 標準來完成的
JWT 是 JSON Web Token 的簡寫,它定義了一種在客戶端和伺服器端安全傳輸資料的規範。通過 JSON 格式 來傳遞資訊
讓我們來假想一下一個場景。在A使用者關注了B使用者的時候,系統發郵件給B使用者,並且附有一個連結“點此關注A使用者”。連結的地址可以是這樣的
https://your.awesome-app.com/make-friend/?from_user=B&target_user=A
上面這樣做有一個弊端,那就是要求使用者B一定要先登入。可不可以簡化這個流程,讓B使用者不用登入就可以完成這個操作。JWT允許我們做到這點
【組成】
一個JWT實際上就是一個字串,它由三部分組成,第一段是 header (頭部),第二段是 payload (主體資訊或稱為載荷),第三段是 signature(數字簽名)
aaaaaaaaaa.bbbbbbbbbbb.cccccccccccc
頭部用於描述關於該JWT的最基本的資訊,例如其型別以及簽名所用的演算法等。這可以被表示成一個JSON物件
{ "typ": "JWT", "alg": "HS256" }
將上面的新增好友的操作描述成一個JSON物件。其中新增了一些其他的資訊,幫助今後收到這個JWT的伺服器理解這個JWT
{ "iss": "John Wu JWT", "iat": 1441593502, "exp": 1441594722, "aud": "www.example.com", "sub": "jrocket@example.com", "from_user": "B", "target_user": "A" }
將上面的JSON物件進行[base64編碼]可以得到下面的字串。這個字串稱作JWT的Payload(載荷)
eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9
將上面的兩個編碼後的字串都用句號.
連線在一起(頭部在前)
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0
最後,我們將上面拼接完的字串用HS256演算法進行加密。在加密的時候,我們還需要提供一個金鑰(secret)。如果我們用mystar
作為金鑰的話,那麼就可以得到我們加密後的內容。這一部分叫做簽名
rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
最後將這一部分簽名也拼接在被簽名的字串後面,我們就得到了完整的JWT
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
於是,我們就可以將郵件中的URL改成
https://your.awesome-app.com/make-friend/?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
再強調一下數字簽名的運算過程
var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload); HMACSHA256(encodedString, 'secret');
簽名是由伺服器完成的,secret 是伺服器上儲存的金鑰,資訊簽名後整個 token 會傳送給瀏覽器,每次瀏覽器 傳送請求中都包含 secret。所以可以跟伺服器達成互信,完成認證過程
認證介面
新建 server/routes.js 檔案,匯入 User 模型並賦值給 User 變數:
let User = require('./models/user')
接下來定義使用者認證介面,將實現的介面名稱為 /auth/login:
module.exports = app => { app.post('/auth/login', (req, res) => { User.findOne({ username: req.body.username }, (err, user) => { if (err) return console.log(err) if (!user) return res.status(403).json({ error: '使用者名稱不存在!' }) user.comparePassword(req.body.password, (err, isMatch) => { if (err) return console.log(err) if (!isMatch) return res.status(403).json({ error: '密碼無效!' }) return res.json({ token: generateToken({ name: user.username }), user: { name: user.username } }) }) }) }) }
使用者從客戶端向伺服器提交使用者名稱和密碼,伺服器端通過body-parser中介軟體把客戶端傳送過的資料抽取出來並存放到 req.body 中,這樣就可以通過 req.body.username 獲取到使用者名稱。然後在 MongoDB 資料庫中查詢這個使用者,若查詢過程中出錯,則列印錯誤資訊到終端;若資料庫中不存在這個使用者,則向客戶端響應錯誤資訊;若資料庫中存在這個使用者,則驗證客戶端提交的密碼 req.body.password 是否與使用者儲存在資料庫中的密碼匹配。若密碼不匹配,則向客戶端返回錯誤資訊;若密碼匹配,則給客戶端返回使用者資訊
使用NPM安裝jsonwebtoken包,jsonwebtoken 包可以生成、驗證和解碼 JWT 認證碼
npm install --save jsonwebtoken
開啟 server/routes.js 檔案,匯入 jsonwebtoken 模組:
let jwt = require('jsonwebtoken')
然後,定義生成 JWT 的 generateToken 方法
let generateToken = (user) => { return jwt.sign(user, 'xiaohuochai', { expiresIn: 3000 }) }
呼叫 jsonwebtoken 模組提供的 sign() 介面生成 JWT。 其中,xiaohuochai 是生成 JWT 認證碼的祕鑰,為了安全,最好把祕鑰放到配置檔案中。 user 是要傳遞給前端的資訊,前端可以利用工具解碼 JWT 認證碼,從而得到 user 資料。 expiresIn 選項用來指定認證碼自生成到失效的時間間隔(過期間隔),上述程式碼中數字 3000 的單位是秒,意思說這個認證碼自生成後,再過50分鐘就失效了。認證碼失效之後,客戶端就不能使用失效的認證碼訪問伺服器端的受保護資源了
完整程式碼如下
let User = require('./models/user') let jwt = require('jsonwebtoken') let secret = require('./config.js').secret let generateToken = (user) => { return jwt.sign(user, secret, { expiresIn: 3000 }) } module.exports = app => { app.post('/auth/login', (req, res) => { User.findOne({ username: req.body.username }, (err, user) => { if (err) return console.log(err) if (!user) return res.status(403).json({ error: '使用者名稱不存在!' }) user.comparePassword(req.body.password, (err, isMatch) => { if (err) return console.log(err) if (!isMatch) return res.status(403).json({ error: '密碼無效!' }) return res.json({ token: generateToken({ name: user.username }), user: { name: user.username } }) }) }) }) }
最後在index.js中引入並使用routes
let routes = require('./routes.js') routes(app)
使用postman來測試介面,已經在資料庫中存了使用者名稱為admin,密碼為123456的使用者。測試結果如下
最後
JWT適合於應用在『無狀態的REST API』,也就是說適用於Android/iOS等移動端,或前後端分離的WEB前端。關於JWT的更多資源移步官網