Node.js RESTful Web API 登入認證令牌範例 for OAuth 2.0 + JWT
詳細解說 OAuth 2.0 的 Password 授權與 JSON Web Token,並結合 Node.js 建置的 RESTful Web API,實作可提供給內部系統使用的登入認證取得令牌與權限控管範例。
OAuth
OAuth (Open Authorization,開放授權) 是一個認證規範的開放標準,主要用於第三方 (如購物網或論壇使用 Facebook 或 Google 等第三方直接登入而無需輸入帳號與密碼) 公開的 API 認證,目前 OAuth 有 1.0 和 2.0 兩個版本,OAuth 2.0 能夠支持各種複雜的認證場景,也是本範例所採用的版本。
Password 授權
OAuth 2.0 授權類型 (Grant Types) 主要有 Password、Authorization Code、Implicit 與 Client Credentials 四種,本範例 Web API 僅提供自有系統使用,不需考慮第三方問題,因此採用使用者 (user) 於登入介面直接輸入帳號與密碼的 Password 授權類型。
Password 是 OAuth 最簡單的授權方式,它僅需一個步驟即可取得訪問 Web API 的令牌 (token)。
請求認證
使用者於登入介面輸入帳號與密碼,來向伺服器 (server) 發送 POST 請求 (request),並指定 application/x-www-form-urlencoded 類型:
POST /oauth/token HTTP/1.1 Host: 192.168.0.200:3000 Content-type: application/x-www-form-urlencoded grant_type=password&username=jacky&password=xxx
名稱 | 說明 |
---|---|
grant_type | 值為 password 是讓伺服器知道使用 Password 授權類型 |
username | 使用者帳號 |
password | 使用者密碼 |
回應 - 成功
伺服器驗證帳號與密碼通過後,伺服器回應 (response) JSON 格式的令牌訊息時,須於 HTTP 首部 (header) 加入 Cache-Control: no-store
和 Pragma: no-cache
,以確保客戶端不會緩存該回應:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJIUiBTeXN0ZW0gV2ViIEFQSSIsImp0aSI6MSwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTUxMzQ2MzQwLCJleHAiOjE1NTEzNDYzNTB9.OX5G4ZMFW0SWjSY9Bsd8KH7JwjhlOxbDi3gedvscWag"
token_type: "bearer"
expires_in: 1551346350
scope: "admin"
}
名稱 | 說明 | 備註 |
---|---|---|
access_token | 訪問 Web API 的令牌 | 本範例使用 JWT |
token_type | "bearer" 為 RFC 6750 定義的 OAuth 2.0 所用的 token 類型 | |
expires_in | 令牌的有效時間 (UNIX 時間戳) | 客戶端應用程式判斷用 |
scope | 允許訪問應用程式 (本範例為 Web API) 的權限範圍 |
回應 - 不成功
回應錯誤的 JSON 必填名稱 error
與 HTTP 狀態碼對應表如下,還有一個 JSON 可選名稱 error_description
可自行簡短描述錯誤說明:
HTTP/1.1 400 Bad Request Content-Type: application/json;charset=UTF-8 Cache-Control: no-store Pragma: no-cache { error: 'unsupported_grant_type', error_description: '授權類型無法識別,本伺服器僅支持 Password 類型!' }
名稱 | 值 | 說明 | 狀態碼 |
---|---|---|---|
error | invalid_request | 參數缺少、未知或重複 | 400 |
invalid_client | 客戶端身份驗證失敗 | 401 | |
invalid_grant | 授權無效或過期 | 400 | |
unauthorized_client | 無權限 | 400 | |
unsupported_grant_type | 授權類型無法識別 | 400 |
令牌訪問 Web API
每次訪問 Web API 須一併將令牌添加到 HTTP 首部的 authorization,於啟始處先輸入 Beare
在使用一個空格 + 令牌:
GET /accounts HTTP/1.1 Host: 192.168.0.200:3000 authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJIUiBTeXN0ZW0gV2ViIEFQSSIsImp0aSI6MSwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTUxNDQ2NjQzLCJleHAiOjE1NTE0NDc2NDN9.Imd7LKXQA0YPWddNbLW_-CvbiEGsSs8rlmNDLpcr0U0
JWT
JWT (JSON Web Token) 是一個用來產生訪問應用程式 (本範例為 Web API) 令牌的開放標準 (RFC 7519),它能夠加密 JSON 來安全的傳輸訊息。
JWT 常使用在這兩種場景:
- 授權:使用者登入後取得 JWT (搭配 OAuth 2.0 存放於回應 JSON 令牌的
access_token
屬性),後續每個訪問 Web API 的請求必須包括 JWT,伺服器會驗證令牌是否合法,也就是沒被篡改。 - 訊息交換:JWT 能夠驗證簽名的 JSON 訊息是否被篡改,因此 JSON 訊息能安全的被傳輸。
數據結構
JWT 由符號 .
分隔三個部份所組成:
- Header (頭部)
- Payload (負載)
- Signature (簽名)
┌──────────────────────────────── Payload ───────────────────────────────┐ │ │ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c │ │ │ │ └────────────── Header ────────────┘ └─────────────── Signature ───────────────┘
Header
Header 是一個 JSON Object,由兩個名稱組成:
- alg:使用的簽名演算法,預設值為 HMAC SHA256 (HS256)。
- type:令牌的類型,這裡使用的是 JWT。
{
"alg": "HS256",
"typ": "JWT"
}
Node.js 範例程式:
var base64url = require('base64url');
var header = '';
header = {
alg: 'HS256',
typ: 'JWT'
};
header = base64url(JSON.stringify(header));
console.log(header);
/* output
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
*/
Payload
Payload 是一個 JSON Object,用來存放實際要傳輸的數據,有三種類型。
- Registered claims (註冊聲名):JWT 標準已註冊的聲名 (非必須,但建議使用)。
- Public claims (公開聲名):自訂的聲名。
- Private claims (私人聲名):本範例未使用。
聲名 | 說明 |
---|---|
iss (issuer,發行人) | 誰申請的 JWT (可填入使用者名稱) |
exp (expiration time,到期時間) | JWT 的到期時間 (UNIX 時間戳) |
sub (subject,主題) | JWT 的主題 (如 HR Web API) |
aud (audience,受眾) | 本範例未使用 |
nbf (Not Before,生效時間) | |
iat (Issued At,簽發時間) | |
jti (JWT ID,JWT 編號) |
以下為有效的 Payload,使用到兩個 JWT 標準聲名 "sub" 和 "iat",以及一個自訂聲名 "name":
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Node.js 範例程式:
var base64url = require('base64url');
var payload = '';
payload = {
sub: '1234567890',
name: 'John Doe',
iat: 1516239022
};
payload = base64url(JSON.stringify(payload));
console.log(payload);
/* output
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
*/
Signature
Signature 部份則使用 Header 中名稱 "alg" 指定的演算法 HS256,對下列三項進行加密產生:
- Base64 URL 編碼的 Header。
- Base64 URL 編碼的 Payload。
- 自訂的 secret (私鑰)。
由於 Signature 使用 secret 方式加密,因此擁有 secret 的伺服器可 verify (驗證) 訊息是否被篡改。
Node.js 程式碼:
var crypto = require('crypto');
var base64url = require('base64url');
var header = '';
var payload = '';
var signature = '';
var secret = 'your-256-bit-secret';
header = {
alg: 'HS256',
typ: 'JWT'
};
header = base64url(JSON.stringify(header));
payload = {
sub: '1234567890',
name: 'John Doe',
iat: 1516239022
};
payload = base64url(JSON.stringify(payload));
signature = crypto.createHmac('SHA256', secret)
.update(header + '.' + payload)
.digest('base64');
signature = base64url.fromBase64(signature);
console.log(signature);
/* output
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
*/
產生 JWT
將上述 Header、Payload 和 Signature 各自產生的結果使用符號 .
串接即為 JWT。
Node.js 程式碼:
var crypto = require('crypto');
var base64url = require('base64url');
var jwt = '';
var header = '';
var payload = '';
var signature = '';
var secret = 'your-256-bit-secret';
header = {
alg: 'HS256',
typ: 'JWT'
};
header = base64url(JSON.stringify(header));
payload = {
sub: '1234567890',
name: 'John Doe',
iat: 1516239022
};
payload = base64url(JSON.stringify(payload));
signature = crypto.createHmac('SHA256', secret)
.update(header + '.' + payload)
.digest('base64');
signature = base64url.fromBase64(signature);
jwt = header + '.' + payload + '.' + signature;
console.log(jwt);
/* output
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
*/
線上調試器
JWT 官網提供一個線上調試器 (Debugger),可自訂所有數據並直接產生 JWT,非常方便在初期架設服務時的測試驗證。
Node.js 實作範例
模組
範例依賴的模組:
- body-parser - npm:解析 HTTP 請求主體的中介軟體。
- cors - npm:跨來源資源共用 (允許不同網域的 HTTP 請求)。
- express - npm:快速建置 Web 應用程式架構。
- jsonwebtoken - npm:JWT 簽名和驗證。
- mysql - npm:連結 MySQL。
程式架構
webapi/ (專案目錄) │ ├── models/ (程式業務邏輯與資料庫存取) │ │ │ ├── accounts.js │ └── oauth2.js (OAuth 2.0、JWT 和相關驗證功能) │ ├── routes/ (負責轉發請求並回應結果) │ │ │ ├── accounts.js │ ├── oauth2-token.js (OAuth 2.0 請求認證與回應令牌) │ └── token-verify.js (token 驗證) │ ├── app.js (應用程式進入點) ├── conf.js (設定檔) └── functions.js (自訂 function)
程式碼
設定檔
conf.js:
module.exports = {
db: {
host: 'localhost',
user: 'root',
password: '',
database: 'test'
},
port: 3000,
// 自訂加密密碼的加鹽
salt: '@2#!A9x?3',
// JWT 自訂私鑰
secret: 'ftP@ssword',
// JWT 加上多少時間過期 (UNIX 時間戳)
increaseTime: 1000
};
自訂 function
functions.js:
var crypto = require('crypto'); // 加解密軟體 (內建模組)
var conf = require('./conf');
module.exports = {
// 將明文密碼加密
passwdCrypto: function (req, res, next) {
if (req.body.password) {
req.body.password = crypto.createHash('md5')
.update(req.body.password + conf.salt)
.digest('hex');
}
next();
}
};
應用程式進入點
app.js:
var bodyparser = require('body-parser'); // 解析 HTTP 請求主體的中介軟體
var express = require('express');
var cors = require('cors'); // 跨來源資源共用 (允許不同網域的 HTTP 請求)
var conf = require('./conf');
var functions = require('./functions');
var oauth2Token = require('./routes/oauth2-token');
var tokenVerify = require('./routes/token-verify');
var accounts = require('./routes/accounts');
var app = express();
app.use(cors());
// 使用 bodyparser.json() 將 HTTP 請求方法 POST、DELETE、PUT 和 PATCH,放在 HTTP 主體 (body) 發送的參數存放在 req.body
app.use(bodyparser.urlencoded({ extended: false }));
app.use(bodyparser.json());
app.use(functions.passwdCrypto);
app.use('/oauth2/token', oauth2Token);
// 不須 token 即可訪問的 Web API 須定義在此上面,通常登入頁面 (此例為登入驗證取得 token 頁面的 /auth2/token)
app.use(tokenVerify);
app.use('/accounts', accounts);
app.listen(conf.port, function () {
console.log('app listening on port ' + conf.port + '!');
});
routes
accounts.js:
var express = require('express');
var oauth2 = require('../models/oauth2');
var accounts = require('../models/accounts');
var router = express.Router();
// oauth2.accessControl 定義在這,對 Web API 的所有 CRUD 確認權限
/*
router.use(oauth2.accessControl, function (req, res, next) {
// 無權限
if (res.customError) {
res.status(res.customStatus).json(res.customError);
return;
}
next();
});
*/
// 獲取 /accounts 請求
router.route('/')
// 取得所有資源
// oauth2.accessControl 定義在這,可針對 Web API 的 CRUD 個別確認權限
.get(oauth2.accessControl, function (req, res) {
// 無權限
if (res.customError) {
res.status(res.customStatus).json(res.customError);
return;
}
accounts.items(req, function (err, results, fields) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
// 沒有找到指定的資源
if (!results.length) {
res.sendStatus(404);
return;
}
res.json(results);
});
})
// 新增一筆資源
.post(function (req, res) {
accounts.add(req, function (err, results, fields) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
// 新的資源已建立 (回應新增資源的 id)
res.status(201).json(results.insertId);
});
});
// 獲取如 /accounts/1 請求
router.route('/:id')
// 取得指定的一筆資源
.get(function (req, res) {
accounts.item(req, function (err, results, fields) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
if (!results.length) {
res.sendStatus(404);
return;
}
res.json(results);
});
})
// 刪除指定的一筆資源
.delete(function (req, res) {
accounts.delete(req, function (err, results, fields) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
// 指定的資源已不存在
// SQL DELETE 成功 results.affectedRows 會返回 1,反之 0
if (!results.affectedRows) {
res.sendStatus(410);
return;
}
// 沒有內容 (成功)
res.sendStatus(204);
});
})
// 覆蓋指定的一筆資源
.put(function (req, res) {
accounts.put(req, function (err, results) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
if (results === 410) {
res.sendStatus(410);
return;
}
accounts.item(req, function (err, results, fields) {
res.json(results);
});
});
})
// 更新指定的一筆資源 (部份更新)
.patch(function (req, res) {
accounts.patch(req, function (err, results, fields) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
if (!results.affectedRows) {
res.sendStatus(410);
return;
}
// response 被更新的資源欄位,但因 request 主體的欄位不包含 id,因此需自行加入
req.body.id = req.params.id;
res.json([req.body]);
});
});
module.exports = router;
oauth2-token.js:
var express = require('express');
var oauth2 = require('../models/oauth2');
var router = express.Router();
router.route('/')
.post(
function (req, res, next) {
// 驗證 OAuth 2.0 授權類型
if (!req.body.grant_type || req.body.grant_type != 'password') {
res.status(400).json({ error: 'unsupported_grant_type', error_description: '授權類型無法識別,本伺服器僅支持 Password 類型!' });
return;
}
oauth2.login(req, function (err, results, fields) {
if (err) {
res.sendStatus(500);
return console.error(err);
}
if (!results.length) {
res.status(401).json({ error: 'invalid_client', error_description: '登入驗證失敗!' });
return;
}
req.results = results;
next();
});
},
function (req, res) {
oauth2.createToken(req, function (results) {
// 確保客戶端瀏覽器不緩存此請求 (OAuth 2.0 標準)
res.header('Cache-Control', 'no-store');
res.header('Pragma', 'no-cache');
res.json(results);
});
});
module.exports = router;
token-verify.js:
var express = require('express');
var oauth2 = require('../models/oauth2');
var router = express.Router();
router.use(oauth2.tokenVerify, function (req, res, next) {
if (res.customError) {
res.status(res.customStatus).json(res.customError);
return;
}
next();
});
module.exports = router;
models
accounts.js:
var mysql = require('mysql');
var conf = require('../conf');
var connection = mysql.createConnection(conf.db);
var tableName = 'accounts';
var sql = '';
module.exports = {
items: function (req, callback) {
sql = 'SELECT * FROM ' + tableName;
return connection.query(sql, callback);
},
item: function (req, callback) {
sql = mysql.format('SELECT * FROM ' + tableName + ' WHERE id = ?', [req.params.id]);
return connection.query(sql, callback);
},
add: function (req, callback) {
sql = mysql.format('INSERT INTO ' + tableName + ' SET ?', req.body);
return connection.query(sql, callback);
},
delete: function (req, callback) {
sql = mysql.format('DELETE FROM ' + tableName + ' WHERE id = ?', [req.params.id]);
return connection.query(sql, callback);
},
put: function (req, callback) {
// 使用 SQL 交易功能實現資料回滾,因為是先刪除資料在新增,且 Key 值須相同,如刪除後發現要新增的資料有誤,則使用 rollback() 回滾
connection.beginTransaction(function (err) {
if (err) throw err;
sql = mysql.format('DELETE FROM ' + tableName + ' WHERE id = ?', [req.params.id]);
connection.query(sql, function (err, results, fields) {
// SQL DELETE 成功 results.affectedRows 會返回 1,反之 0
if (results.affectedRows) {
req.body.id = req.params.id;
sql = mysql.format('INSERT INTO ' + tableName + ' SET ?', req.body);
connection.query(sql, function (err, results, fields) {
// 請求不正確
if (err) {
connection.rollback(function () {
callback(err, 400);
});
} else {
connection.commit(function (err) {
if (err) callback(err, 400);
callback(err, 200);
});
}
});
} else {
// 指定的資源已不存在
callback(err, 410);
}
});
});
},
patch: function (req, callback) {
sql = mysql.format('UPDATE ' + tableName + ' SET ? WHERE id = ?', [req.body, req.params.id]);
return connection.query(sql, callback);
}
};
oauth2.js:
var mysql = require('mysql');
var jwt = require('jsonwebtoken'); // JWT 簽名和驗證
var conf = require('../conf');
var connection = mysql.createConnection(conf.db);
var tableName = 'accounts';
var sql;
module.exports = {
// 使用者登入認證
login: function (req, callback) {
sql = mysql.format('SELECT * FROM ' + tableName + ' WHERE username = ? AND password = ?', [req.body.username, req.body.password]);
return connection.query(sql, callback);
},
// 產生 OAuth 2.0 和 JWT 的 JSON 格式令牌訊息
createToken: function (req, callback) {
let payload = {
iss: req.results[0].username,
sub: 'HR System Web API',
role: req.results[0].role // 自訂聲明。用來讓伺服器確認使用者的角色權限 (決定使用者能使用 Web API 的權限)
};
// 產生 JWT
let token = jwt.sign(payload, conf.secret, {
algorithm: 'HS256',
expiresIn: conf.increaseTime + 's' // JWT 的到期時間 (當前 UNIX 時間戳 + 設定的時間)。必須加上時間單位,否則預設為 ms (毫秒)
})
// JSON 格式符合 OAuth 2.0 標準,除自訂 info 屬性是為了讓前端取得額外資訊 (例如使用者名稱),
return callback({
access_token: token,
token_type: 'bearer',
expires_in: (Date.parse(new Date()) / 1000) + conf.increaseTime, // UNIX 時間戳 + conf.increaseTime
scope: req.results[0].role,
info: {
username: req.results[0].username
}
});
},
// 驗證 JWT
tokenVerify: function (req, res, next) {
// 沒有 JWT
if (!req.headers.authorization) {
res.customStatus = 401;
res.customError = { error: 'invalid_client', error_description: '沒有 token!' };
}
if (req.headers.authorization && req.headers.authorization.split(' ')[0] == 'Bearer') {
jwt.verify(req.headers.authorization.split(' ')[1], conf.secret, function (err, decoded) {
if (err) {
res.customStatus = 400;
switch (err.name) {
// JWT 過期
case 'TokenExpiredError':
res.customError = { error: 'invalid_grant', error_description: 'token 過期!' };
break;
// JWT 無效
case 'JsonWebTokenError':
res.customError = { error: 'invalid_grant', error_description: 'token 無效!' };
break;
}
} else {
req.user = decoded;
}
});
}
next();
},
// Web API 存取控制
accessControl: function (req, res, next) {
console.log(req.user);
// 如不是 admin,則無權限
switch (req.user.role) {
case null:
case 'user':
case 'guest':
res.customStatus = 400;
res.customError = { error: 'unauthorized_client', error_description: '無權限!' };
break;
}
next();
}
};
下載範例
下載範例後的建立步驟如下:
- 至 MySQL 執行範例內的檔案 accounts.sql (已建立的兩個帳號 password 同 username)。
- 編輯設定檔修改資料庫設定。
- 將範例內的目錄 webapi 放置可運行 Node.js 的環境,執行
npm install
安裝所有依賴模組。
npm install
npm WARN webapi@1.0.0 No description
npm WARN webapi@1.0.0 No repository field.
added 77 packages from 58 contributors and audited 184 packages in 2.743s
found 0 vulnerabilities
測試
啟動應用程式:
npm install
app listening on port 3000!
使用 Chrome 擴充功能應用程式 Advanced REST client 進行測試。
請求認證
HTTP 頭部指定 application/x-www-form-urlencoded 類型:
輸入請求參數點擊 SEND,驗證成功會回應 JSON,複製最下方回應的 JWT:
令牌訪問 Web API
指定 HTTP 首部的 authorization,於啟始處先輸入 Beare
在使用一個空格 + 貼上上述剛複製的 JWT 後點擊 SEND,即可取得請求 Web API 的資源:
參考
本著作係採用創用 CC 姓名標示-相同方式分享 3.0 台灣 授權條款授權.