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 台灣 授權條款授權.
