Node.js RESTful Web API 登入認證令牌範例 for OAuth 2.0 + JWT

Node.js

詳細解說 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
OAuth 2.0 Password 授權類型 - POST 請求認證參數
名稱 說明
grant_type 值為 password 是讓伺服器知道使用 Password 授權類型
username 使用者帳號
password 使用者密碼

回應 - 成功

伺服器驗證帳號與密碼通過後,伺服器回應 (response) JSON 格式的令牌訊息時,須於 HTTP 首部 (header) 加入 Cache-Control: no-storePragma: 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"
}
OAuth 2.0 Password 授權類型 - 回應訊息
名稱 說明 備註
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 值的說明與對應 HTTP 狀態碼
名稱 說明 狀態碼
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,用來存放實際要傳輸的數據,有三種類型。

JWT - 標準聲名
聲名 說明
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 實作範例

模組

範例依賴的模組:

程式架構

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 的資源:

參考

發表留言