OAuth和Node.js 工作原理 客戶端 授權服務端 資源服務器 參考資料

OAuth是一個開源的標準協議,主要用於應用之間的授權訪問。OAuth1.0和2.0之間有較大出入,互相不兼容,因此目前主流的應用都是使用2.0版本的協議。

OAuth應用十分廣泛,比如google, Facebook, twitter和微博之類的應用都會開放出對應的OAuth接口,便於第三方應用使用已有賬號進行授權訪問特定資源。

工作原理

在OAuth 2.0中定義有4個角色,它們分別是:

  • 資源擁有者(Resource Owner) 也就是通常意義下的用戶。
  • 客戶端(Client): 比較常見的是瀏覽器
  • 資源服務器(Resource Server): 存放資源的服務器
  • 授權服務器(Authorization Server): 專門用來處理認證的服務器

整個OAuth 認證過程中的流程是:

  1. 客戶端詢問用戶是否進行授權
  2. 用戶允許授權後,客戶端向認證服務器發送請求,認證服務器此時會發給客戶端一個token
  3. 客戶端拿到這個token再向資源服務器獲得資源

整個流程和傳統的認證請求不同的地方主要就是中間加入了一層授權服務器機制,爲了資源訪問的安全性,要訪問資源不是直接通過用戶密碼的方式進行的,而是採用token的形式。

客戶端

客戶端主要實現以下兩個功能:

  • 重定向到認證服務器上獲取權限

  • 使用從服務器獲取的token訪問受保護的資源

而要獲得對應的access token, 客戶端通常是使用授權碼(authorization code)模式進行授權的。主要流程有:

  • 打開客戶端,查看是否用戶是否登錄,如果沒有登錄將用戶導向認證服務器
  • 申請認證過程中,客戶端的uri會帶上一堆參數,包括了客戶端ID(client_id)、授權類型(response_type: code)、資源訪問範圍(scope)、客戶端狀態(state)以及重定向uri(redirect_uri)。
  • 用戶在新的地址登錄成功後,服務器會生成授權碼(authentication_code)並附在重定向uri上,返回給客戶端
  • 客戶端拿着授權碼進一步訪問認證服務器,獲得token
  • 客戶端拿到access token後就可以進一步請求所需要的資源了

下面這段node.js代碼可以描述上述的工作流程(不保證可運行,原理是一樣的):

// authorization server information
const authServer = {
    authorizationEndpoint: 'http://localhost:3000/authorize',
    tokenEndpoint: 'http://localhost:3000/token'
};

// 客戶端信息
const client = {
    "client_id": "xxx",
    "client_secret": "xxx",
    "redirect_uris": ["http://localhost:9000/callback"],
    "scope": "xxx"
};

app.get('/authorize', function (req, res) {
    // 隨機生成state
    state = randomstring.generate();
    const authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
        response_type: 'code',
        scope: client.scope,
        client_id: client.client_id,
        redirect_uri: client.redirect_uris[0],
        state: state
    });

    res.redirect(authorizeUrl);
});

app.get("/callback", function (req, res) {

    const resState = req.query.state;
    // 判斷resState與生成的state是否相同 , 此處省略

    var code = req.query.code;

    var form_data = qs.stringify({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: client.redirect_uris[0]
    });
    var headers = {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + Buffer.from(querystring.escape(client.client_id) + ':' + querystring.escape(client.client_secret)).toString('base64')
    };

    // 請求認證服務器,獲取token
    var tokRes = request('POST', authServer.tokenEndpoint,
        {
            body: form_data,
            headers: headers
        }
    );

    if (tokRes.statusCode >= 200 && tokRes.statusCode < 300) {
        var body = JSON.parse(tokRes.getBody());

        if (body.access_token) {
            // 拿到token後就可以訪問資源了
            // ...
        }
    }
});

授權服務端

授權服務器的職責主要是校驗客戶端,生成token,刷新token並管理不同客戶端的token。

下面是不完全代碼:

var refreshTokens = {};
var accessTokens = [];
var codes = {};
var requests = {};

var clients = [
    {client_id: 'xxx'} 
]; //所有有效的客戶端

app.get("/authorize", function(req, res){
    // 通過url中的client_id, 獲取客戶端信息
    var client = getClient(req.query.client_id);
    
    // 此處省略錯誤處理
    if (client) {
        var rscope = req.query.scope ? req.query.scope.split(' ') : undefined;
        var cscope = client.scope ? client.scope.split(' ') : undefined;
        if (__.difference(rscope, cscope).length > 0) {
            // 如果服務端scope和客戶端scope不匹配的話,返回錯誤信息
            res.redirect(buildUrl(req.query.redirect_uri, {
                error: 'invalid_scope'
            }));
            return;
        }
        
        // 此處也可以使用session進行判斷
        var reqid = randomstring.generate(8);
        requests[reqid] = req.query;

        // 允許授權
        res.render('approve', {client: client, reqid: reqid, scope: rscope});
        return;
    } 
    
});

app.post('/approve', function(req, res) {

    var reqid = req.body.reqid;
    var query = requests[reqid];
    delete requests[reqid];

    if (!query) {
        res.render('error', {error: 'No matching authorization request'});
        return;
    }

    if (req.body.approve) {
        if (query.response_type == 'code') {
            // 用戶授權訪問
            var rscope = getScopesFromForm(req.body);
            var client = getClient(query.client_id);
            var cscope = client.scope ? client.scope.split(' ') : undefined;
            if (__.difference(rscope, cscope).length > 0) {
                // 判斷scope
                res.redirect(buildUrl(query.redirect_uri, {
                    error: 'invalid_scope'
                }));
                return;
            }

            var code = randomstring.generate(8);
            codes[code] = { request: query, scope: rscope };

            var urlParsed = buildUrl(query.redirect_uri, {
                code: code,
                state: query.state
            });
            res.redirect(urlParsed);
            return;
            
        } else {
            var urlParsed = buildUrl(query.redirect_uri, {
                error: 'unsupported_response_type'
            });
            res.redirect(urlParsed);
        }

    } else {
        var urlParsed = buildUrl(query.redirect_uri, {
            error: 'access_denied'
        });
        res.redirect(urlParsed);
        return;
    }
});

app.post("/token", function(req, res){
    
    var auth = req.headers['authorization'];
    if (auth) {
        // 檢查請求頭
        var clientCredentials = decodeClientCredentials(auth);
        var clientId = clientCredentials.id;
        var clientSecret = clientCredentials.secret;
    }
    
    // 檢查請求體
    if (req.body.client_id) {
        if (clientId) {
            // 如果已經在請求頭上檢查到認證信息的話,判定爲錯誤
            console.log('Client attempted to authenticate with multiple methods');
            res.status(401).json({error: 'invalid_client'});
            return;
        }
        
        var clientId = req.body.client_id;
        var clientSecret = req.body.client_secret;
    }
    
    var client = getClient(clientId);
    // 此處省略客戶端信息判斷錯誤處理

    if (req.body.grant_type == 'authorization_code') {
        var code = codes[req.body.code];
        
        if (code) {
            delete codes[req.body.code]; // 表示該code已被使用
            if (code.request.client_id == clientId) {
                var access_token = generateAccessToken(code) // 使用Bearer Tokens算法生成token,推薦jwt庫
                
                // 將生成的token持久化,如果是生產環境,一般需要保存到數據庫中
                accessTokens.push({ access_token: access_token, client_id: clientId, scope: code.scope });

                // 刷新token,便於下次使用
                var refreshToken = randomstring.generate();
                refreshTokens[refreshToken] = { clientId: clientId };

                // 將token信息返回
                var token_response = { access_token: access_token, token_type: 'Bearer',  scope: code.scope.join(' '), refresh_token: refreshToken };
                res.status(200).json(token_response);
                return;
            } else {
                res.status(400).json({error: 'invalid_grant'});
                return;
            }
        }
    } else if (req.body.grant_type == 'refresh_token') {
        // 刷新token算法,與生成token的算法大同小異
    }
});

目前比較成熟的Node.js oauth2框架可以參考https://github.com/jaredhanson/oauth2orize,例子項目可以參考https://github.com/awais786327/oauth2orize-examples

資源服務器

資源服務器的功能相對簡單些,主要是驗證token的有效性,然後根據不同的scope,來控制資源的權限。

參考資料

https://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html

https://en.wikipedia.org/wiki/OAuth

https://developer.okta.com/blog/2017/06/21/what-the-heck-is-oauth

https://oauth.net/2/

https://app.pluralsight.com/library/courses/understanding-oauth-with-nodejs/table-of-contents

——--轉載請註明出處--———


最後,歡迎大家關注我的公衆號,一起學習交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章