1. 程式人生 > >使用 AngularJS & NodeJS 實現基於 token 的認證應用

使用 AngularJS & NodeJS 實現基於 token 的認證應用

使用 AngularJS & NodeJS 實現基於 token 的認證應用

 · 11 個月前

認證是任何 web 應用中不可或缺的一部分。在這個教程中,我們會討論基於 token 的認證系統以及它和傳統的登入系統的不同。這篇教程的末尾,你會看到一個使用 AngularJS 和 NodeJS 構建的完整的應用。

傳統的認證系統

在開始說基於 token 的認證系統之前,我們先看一下傳統的認證系統。

  1. 使用者在登入域輸入 使用者名稱 和 密碼 ,然後點選 登入 ;

  2. 請求傳送之後,通過在後端查詢資料庫驗證使用者的合法性。如果請求有效,使用在資料庫得到的資訊建立一個 session,然後在響應頭資訊中返回這個 session 的資訊,目的是把這個 session ID 儲存到瀏覽器中;
  3. 在訪問應用中受限制的後端伺服器時提供這個 session 資訊;
  4. 如果 session 資訊有效,允許使用者訪問受限制的後端伺服器,並且把渲染好的 HTML 內容返回。

在這之前一切都很美好。web 應用正常工作,並且它能夠認證使用者資訊然後可以訪問受限的後端伺服器;然而當你在開發其他終端時發生了什麼呢,比如在 Android 應用中?你還能使用當前的應用去認證移動端並且分發受限制的內容麼?真相是,不可以。有兩個主要的原因:

  1. 在移動應用上 session 和 cookie 行不通。你無法與移動終端共享伺服器建立的 session 和 cookie。

  2. 在這個應用中,渲染好的 HTML 被返回。但在移動端,你需要包含一些類似 JSON 或者 XML 的東西包含在響應中。

在這個例子中,需要一個獨立客戶端服務。

基於 token 的認證

在基於 token 的認證裡,不再使用 cookie 和session。token 可被用於在每次向伺服器請求時認證使用者。我們使用基於 token 的認證來重新設計剛才的設想。

將會用到下面的控制流程:

  1. 使用者在登入表單中輸入 使用者名稱 和 密碼 ,然後點選 登入 ;

  2. 請求傳送之後,通過在後端查詢資料庫驗證使用者的合法性。如果請求有效,使用在資料庫得到的資訊建立一個 token,然後在響應頭資訊中返回這個的資訊,目的是把這個 token 儲存到瀏覽器的本地儲存中;
  3. 在每次傳送訪問應用中受限制的後端伺服器的請求時提供 token 資訊;
  4. 如果從請求頭資訊中拿到的 token 有效,允許使用者訪問受限制的後端伺服器,並且返回 JSON 或者 XML。

在這個例子中,我們沒有返回的 session 或者 cookie,並且我們沒有返回任何 HTML 內容。那意味著我們可以把這個架構應用於特定應用的所有客戶端中。你可以看一下面的架構體系:

那麼,這裡的 JWT 是什麼?

JWT

JWT 代表 JSON Web Token ,它是一種用於認證頭部的 token 格式。這個 token 幫你實現了在兩個系統之間以一種安全的方式傳遞資訊。出於教學目的,我們暫且把 JWT 作為“不記名 token”。一個不記名 token 包含了三部分:header,payload,signature。

  • header 是 token 的一部分,用來存放 token 的型別和編碼方式,通常是使用 base-64 編碼。

  • payload 包含了資訊。你可以存放任一種資訊,比如使用者資訊,產品資訊等。它們都是使用 base-64 編碼方式進行儲存。
  • signature 包括了 header,payload 和金鑰的混合體。金鑰必須安全地儲存儲在服務端。

你可以在下面看到 JWT 剛要和一個例項 token:

你不必關心如何實現不記名 token 生成器函式,因為它對於很多常用的語言已經有多個版本的實現。下面給出了一些:

NodeJS: auth0/node-jsonwebtoken · GitHub

一個例項

在討論了關於基於 token 認證的一些基礎知識後,我們接下來看一個例項。看一下下面的幾點,然後我們會仔細的分析它:


  1. 多個終端,比如一個 web 應用,一個移動端等向 API 傳送特定的請求。

  2. 一個應用可能會被部署到多個伺服器上(server-1, server-2, ..., server-n)。當有請求傳送到[api.yourexampleapp.com](api.yourexampleapp.com) 時,後端的應用會攔截這個請求頭部並且從認證頭部中提取到 token 資訊。使用這個 token 查詢資料庫。如果這個 token 有效並且有請求終端資料所必須的許可時,請求會繼續。如果無效,會返回 403 狀態碼(表明一個拒絕的狀態)。

優勢

基於 token 的認證在解決棘手的問題時有幾個優勢:

  • Client Independent Services 。在基於 token 的認證,token 通過請求頭傳輸,而不是把認證資訊儲存在 session 或者 cookie 中。這意味著無狀態。你可以從任意一種可以傳送 HTTP 請求的終端向伺服器傳送請求。
  • CDN 。在絕大多數現在的應用中,view 在後端渲染,HTML 內容被返回給瀏覽器。前端邏輯依賴後端程式碼。這中依賴真的沒必要。而且,帶來了幾個問題。比如,你和一個設計機構合作,設計師幫你完成了前端的 HTML,CSS 和 JavaScript,你需要拿到前端程式碼並且把它移植到你的後端程式碼中,目的當然是為了渲染。修改幾次後,你渲染的 HTML 內容可能和設計師完成的程式碼有了很大的不同。在基於 token 的認證中,你可以開發完全獨立於後端程式碼的前端專案。後端程式碼會返回一個 JSON 而不是渲染 HTML,並且你可以把最小化,壓縮過的程式碼放到 CDN 上。當你訪問 web 頁面,HTML 內容由 CDN 提供服務,並且頁面內容是通過使用認證頭部的 token 的 API 服務所填充。
  • No Cookie-Session (or No CSRF) 。CSRF 是當代 web 安全中一處痛點,因為它不會去檢查一個請求來源是否可信。為了解決這個問題,一個 token 池被用在每次表單請求時傳送相關的 token。在基於 token 的認證中,已經有一個 token 應用在認證頭部,並且 CSRF 不包含那個資訊。
  • Persistent Token Store 。當在應用中進行 session 的讀,寫或者刪除操作時,會有一個檔案操作發生在作業系統的temp 資料夾下,至少在第一次時。假設有多臺伺服器並且 session 在第一臺服務上建立。當你再次傳送請求並且這個請求落在另一臺伺服器上,session 資訊並不存在並且會獲得一個“未認證”的響應。我知道,你可以通過一個粘性 session 解決這個問題。然而,在基於 token 的認證中,這個問題很自然就被解決了。沒有粘性 session 的問題,因為在每個傳送到伺服器的請求中這個請求的 token 都會被攔截。

這些就是基於 token 的認證和通訊中最明顯的優勢。基於 token 認證的理論和架構就說到這裡。下面上例項。

應用例項

你會看到兩個用於展示基於 token 認證的應用:

  1. token-based-auth-backend
  2. token-based-auth-frontend

在後端專案中,包括服務介面,服務返回的 JSON 格式。服務層不會返回檢視。在前端專案中,會使用 AngularJS 向後端服務傳送請求。

token-based-auth-backend

在後端專案中,有三個主要檔案:

  • package.json 用於管理依賴;

  • models\User.js 包含了可能被用於處理關於使用者的資料庫操作的使用者模型;
  • server.js 用於專案引導和請求處理。

就是這樣!這個專案非常簡單,你不必深入研究就可以瞭解主要的概念。

{
    "name": "angular-restful-auth",
    "version": "0.0.1",
    "dependencies": {
        "express": "4.x",
        "body-parser": "~1.0.0",
        "morgan": "latest",
        "mongoose": "3.8.8",
        "jsonwebtoken": "0.4.0"
    },
    "engines": {
        "node": ">=0.10.0"
    }
}

package.json包含了這個專案的依賴:express 用於 MVC,body-parser 用於在 NodeJS 中模擬 post 請求操作,morgan 用於請求登入,mongoose 用於為我們的 ORM 框架連線 MongoDB,最後 jsonwebtoken 用於使用我們的 User 模型建立 JWT 。如果這個專案使用版本號 >= 0.10.0 的 NodeJS 建立,那麼還有一個叫做 engines 的屬性。這對那些像 HeroKu 的 PaaS 服務很有用。我們也會在另外一節中包含那個話題。

var mongoose     = require('mongoose');
var Schema       = mongoose.Scema;

var UserSchema   = new Schema({
    email: String,
    password: String,
    token: String
});

module.exports = mongoose.model('User', UserSchema);

上面提到我們可以通過使用使用者的 payload 模型生成一個 token。這個模型幫助我們處理使用者在 MongoDB 上的請求。在User.js,user-schema 被定義並且 User 模型通過使用 mogoose 模型被建立。這個模型提供了資料庫操作。

我們的依賴和 user 模型被定義好,現在我們把那些構想成一個服務用於處理特定的請求。

// Required Modules
var express    = require("express");
var morgan     = require("morgan");
var bodyParser = require("body-parser");
var jwt        = require("jsonwebtoken");
var mongoose   = require("mongoose");
var app        = express();

在 NodeJS 中,你可以使用 require 包含一個模組到你的專案中。第一步,我們需要把必要的模組引入到專案中:

var port = process.env.PORT || 3001;
var User     = require('./models/User');
// Connect to DB
mongoose.connect(process.env.MONGO_URL);

服務層通過一個指定的埠提供服務。如果沒有在環境變數中指定埠,你可以使用那個,或者我們定義的 3001 埠。然後,User 模型被包含,並且資料庫連線被建立用來處理一些使用者操作。不要忘記定義一個 MONGO_URL 環境變數,用於資料庫連線 URL。

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(morgan("dev"));
app.use(function(req, res, next) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
    res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type, Authorization');
    next();
});

上一節中,我們已經做了一些配置用於在 NodeJS 中使用 Express 模擬一個 HTTP 請求。我們允許來自不同域名的請求,目的是建立一個獨立的客戶端系統。如果你沒這麼做,可能會觸發瀏覽器的 CORS(跨域請求共享)錯誤。

  • Access-Control-Allow-Origin 允許所有的域名。

  • 你可以向這個裝置傳送 POST 和 GET 請求。
  • 允許 X-Requested-With 和 content-type 頭部。
app.post('/authenticate', function(req, res) {
    User.findOne({email: req.body.email, password: req.body.password}, function(err, user) {
        if (err) {
            res.json({
                type: false,
                data: "Error occured: " + err
            });
        } else {
            if (user) {
               res.json({
                    type: true,
                    data: user,
                    token: user.token
                }); 
            } else {
                res.json({
                    type: false,
                    data: "Incorrect email/password"
                });    
            }
        }
    });
});

我們已經引入了所需的全部模組並且定義了配置檔案,所以是時候來定義請求處理函數了。在上面的程式碼中,當你提供了使用者名稱和密碼向 /authenticate 傳送一個 POST 請求時,你將會得到一個 JWT。首先,通過使用者名稱和密碼查詢資料庫。如果使用者存在,使用者資料將會和它的 token 一起返回。但是,如果沒有使用者名稱或者密碼不正確,要怎麼處理呢?

app.post('/signin', function(req, res) {
    User.findOne({email: req.body.email, password: req.body.password}, function(err, user) {
        if (err) {
            res.json({
                type: false,
                data: "Error occured: " + err
            });
        } else {
            if (user) {
                res.json({
                    type: false,
                    data: "User already exists!"
                });
            } else {
                var userModel = new User();
                userModel.email = req.body.email;
                userModel.password = req.body.password;
                userModel.save(function(err, user) {
                    user.token = jwt.sign(user, process.env.JWT_SECRET);
                    user.save(function(err, user1) {
                        res.json({
                            type: true,
                            data: user1,
                            token: user1.token
                        });
                    });
                })
            }
        }
    });
});

當你使用使用者名稱和密碼向 /signin 傳送 POST 請求時,一個新的使用者會通過所請求的使用者資訊被建立。在 第 19 行,你可以看到一個新的 JSON 通過 jsonwebtoken 模組生成,然後賦值給 jwt 變數。認證部分已經完成。我們訪問一個受限的後端伺服器會怎麼樣呢?我們又要如何訪問那個後端伺服器呢?

app.get('/me', ensureAuthorized, function(req, res) {
    User.findOne({token: req.token}, function(err, user) {
        if (err) {
            res.json({
                type: false,
                data: "Error occured: " + err
            });
        } else {
            res.json({
                type: true,
                data: user
            });
        }
    });
});

當你向 /me 傳送 GET 請求時,你將會得到當前使用者的資訊,但是為了繼續請求後端伺服器, ensureAuthorized 函式將會執行。

function ensureAuthorized(req, res, next) {
    var bearerToken;
    var bearerHeader = req.headers["authorization"];
    if (typeof bearerHeader !== 'undefined') {
        var bearer = bearerHeader.split(" ");
        bearerToken = bearer[1];
        req.token = bearerToken;
        next();
    } else {
        res.send(403);
    }
}

在這個函式中,請求頭部被攔截並且 authorization 頭部被提取。如果頭部中存在一個不記名 token,通過呼叫 next()函式,請求繼續。如果 token 不存在,你會得到一個 403(Forbidden)返回。我們回到 /me 事件處理函式,並且使用req.token 獲取這個 token 對應的使用者資料。當你建立一個新的使用者,會生成一個 token 並且儲存到資料庫的使用者模型中。那些 token 都是唯一的。

這個簡單的例子中已經有三個事件處理函式。然後,你將看到;

process.on('uncaughtException', function(err) {
    console.log(err);
});

當程式出錯時 NodeJS 應用可能會崩潰。新增上面的程式碼可以拯救它並且一個錯誤日誌會打到控制檯上。最終,我們可以使用下面的程式碼片段啟動服務。

// Start Server
app.listen(port, function () {
    console.log( "Express server listening on port " + port);
});

總結一下:

  • 引入模組

  • 正確配置
  • 定義請求處理函式
  • 定義用來攔截受限終點資料的中介軟體
  • 啟動服務

我們已經完成了後端服務。到現在,應用已經可以被多個終端使用,你可以部署這個簡單的應用到你的伺服器上,或者部署在 Heroku。有一個叫做 Procfile 的檔案在專案的根目錄下。現在把服務部署到 Heroku。

Heroku 部署

你可以在這個 GitHub 庫下載專案的後端程式碼。

我不會教你如何在 Heroku 如何建立一個應用;如果你還沒有做過這個,你可以查閱這篇文章。建立完 Heroku 應用,你可以使用下面的命令為你的專案新增一個地址:

git remote add heroku <your_heroku_git_url>

現在,你已經克隆了這個專案並且添加了地址。在 git add 和 git commit 後,你可以使用 git push heroku master 命令將你的程式碼推到 Heroku。當你成功將專案推送到倉庫,Heroku 會自動執行 npm install 命令將依賴檔案下載到 Heroku 的 temp 資料夾。然後,它會啟動你的應用,因此你就可以使用 HTTP 協議訪問這個服務。

token-based-auth-frontend

在前端專案中,將會使用 AngularJS。在這裡,我只會提到前端專案中的主要內容,因為 AngularJS 的相關知識不會包括在這個教程裡。

你可以在這個 GitHub 庫下載原始碼。在這個專案中,你會看下下面的檔案結構:

ngStorage.js 是一個用於操作本地儲存的 AngularJS 類庫。此外,有一個全域性的 layout 檔案 index.html 並且在 partials 資料夾裡還有一些用於擴充套件全域性 layout 的部分。 controllers.js 用於在前端定義我們 controller 的 action。 services.js 用於向我們在上一個專案中提到的服務傳送請求。還有一個 app.js 檔案,它裡面有配置檔案和模組引入。最後,client.js 用於服務靜態 HTML 檔案(或者僅僅 index.html,在這裡例子中);當你沒有使用 Apache 或者任何其他的 web 伺服器時,它可以為靜態的 HTML 檔案提供服務。

...
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.20/angular.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.20/angular-route.min.js"></script>
<script src="/lib/ngStorage.js"></script>
<script src="/lib/loading-bar.js"></script>
<script src="/scripts/app.js"></script>
<script src="/scripts/controllers.js"></script>
<script src="/scripts/services.js"></script>
</body>

在全域性的 layout 檔案中,AngularJS 所需的全部 JavaScript 檔案都被包含,包括自定義的控制器,服務和應用檔案。

'use strict';

/* Controllers */

angular.module('angularRestfulAuth')
    .controller('HomeCtrl', ['$rootScope', '$scope', '$location', '$localStorage', 'Main', function($rootScope, $scope, $location, $localStorage, Main) {

        $scope.signin = function() {
            var formData = {
                email: $scope.email,
                password: $scope.password
            }

            Main.signin(formData, function(res) {
                if (res.type == false) {
                    alert(res.data)    
                } else {
                    $localStorage.token = res.data.token;
                    window.location = "/";    
                }
            }, function() {
                $rootScope.error = 'Failed to signin';
            })
        };

        $scope.signup = function() {
            var formData = {
                email: $scope.email,
                password: $scope.password
            }

            Main.save(formData, function(res) {
                if (res.type == false) {
                    alert(res.data)
                } else {
                    $localStorage.token = res.data.token;
                    window.location = "/"    
                }
            }, function() {
                $rootScope.error = 'Failed to signup';
            })
        };

        $scope.me = function() {
            Main.me(function(res) {
                $scope.myDetails = res;
            }, function() {
                $rootScope.error = 'Failed to fetch details';
            })
        };

        $scope.logout = function() {
            Main.logout(function() {
                window.location = "/"
            }, function() {
                alert("Failed to logout!");
            });
        };
        $scope.token = $localStorage.token;
    }])

在上面的程式碼中,HomeCtrl 控制器被定義並且一些所需的模組被注入(比如 $rootScope 和 $scope)。依賴注入是 AngularJS 最強大的屬性之一。 $scope 是 AngularJS 中的一個存在於控制器和檢視之間的中間變數,這意味著你可以在檢視中使用 test,前提是你在特定的控制器中定義了 $scope.test=....。

在控制器中,一些工具函式被定義,比如:

  • signin 可以在登入表單中初始化一個登入按鈕;

  • signup 用於處理註冊操作;
  • me 可以在 layout 中生生一個 Me 按鈕;

在全域性 layout 和主選單列表中,你可以看到 data-ng-controller 這個屬性,它的值是 HomeCtrl。那意味著這個選單的 dom 元素可以和 HomeCtrl 共享作用域。當你點選表單裡的 sign-up 按鈕時,控制器檔案中的 sign-up 函式將會執行,並且在這個函式中,使用的登入服務來自於已經注入到這個控制器的 Main 服務。

主要的結構是 view -> controller -> service。這個服務向後端傳送了簡單的 Ajax 請求,目的是獲取指定的資料。

'use strict';

angular.module('angularRestfulAuth')
    .factory('Main', ['$http', '$localStorage', function($http, $localStorage){
        var baseUrl = "your_service_url";
        function changeUser(user) {
            angular.extend(currentUser, user);
        }

        function urlBase64Decode(str) {
            var output = str.replace('-', '+').replace('_', '/');
            switch (output.length % 4) {
                case 0:
                    break;
                case 2:
                    output += '==';
                    break;
                case 3:
                    output += '=';
                    break;
                default:
                    throw 'Illegal base64url string!';
            }
            return window.atob(output);