Node with React: Fullstack Web Development 課程手記(二)——Google OAuth

妖僧風月發表於2017-11-06

OAuth

OAuth是一個關於授權(authorization)的開放網路標準,在全世界得到廣泛應用,目前的版本是2.0版。常見的採用微信、QQ、微博、Facebook、Google賬號登陸網站的過程都是採用了OAuth技術。這一章我們會以使用Google賬號登陸第三方網站為例,展示如何使用這項技術。

Google OAuth工作流程

  • 整個OAuth過程主要設計三個方面,客戶端(對於網站而言則是瀏覽器)、第三方伺服器(對應網站的伺服器)和Google伺服器。當使用者點選使用Google賬號登陸網站時,第三方伺服器會直接把這個請求傳遞給Google伺服器,響應後頁面跳轉至Google的驗證授權頁面,詢問使用者是否同意授權。使用者同意後,谷歌伺服器會跳轉至第三方伺服器中,並且在跳轉URL上會攜帶一個code引數,第三方伺服器拿到code後會憑藉這個code再次向Google伺服器傳送請求,並換取使用者資訊。拿到使用者資訊後,第三方伺服器會檢查資料庫,如果沒有這個使用者則存入資料庫,並登陸成功,如果有則直接登陸成功。與此同時,給瀏覽器種一個標識使用者資訊的cookie,此後在cookie的有效期內,瀏覽器接下來每次對第三方伺服器的請求中都會攜帶cookie,因此可以表示使用者身份,做一些需要許可權才能做的事情。具體流程如下圖所示:
  • 我使用passport這個庫幫助我們實現驗證流程。

    passportJS

  • 兩個問題:
    • passportJS會自動化OAuth流程,但需要程式碼深入到流程細節中,並不能完全自動化整個流程
    • 庫的結構,實際上我們需要兩個庫才能使用passportJS——passport、passport strategy,第一個是核心庫,用以提供驗證流程的工具方法,第二個是針對不同的授權提供方(Google、Facebook、Wechat etc.)所需要的定製方法,也就是說你如果需要同時提供Google、Facebook、Wechat三種驗證方式,那你就需要三個strategy庫。在 passportjs.org中提供了很多strategies庫。
  • 安裝passport到專案中
    npm install --save passport passport-google-oauth20複製程式碼
  • 20的意思是版本為2.0,因為npm的包名稱中不能有.,所以就起名為20了,其實這裡也可以不加20,那麼安裝的就是一個1.02.0的組合版。鑑於現在基本知名的auth provider都已經支援OAuth2.0,所以這裡採用2.0版本。詳情參見passport-google-oauth github
  • 使用passport
const passport  = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

passport.use(new GoogleStrategy());複製程式碼
  • 在使用Google OAuth之前,需要兩個引數appid和api secrect,要獲取這兩個引數,需要在 console.developers.google.com 上建立專案(只要有Google賬號,very easy)
  • 建立完專案,進入專案皮膚,進入api板塊,點選啟用Google API,搜尋Google +,選擇 Google + API, 點選啟用
  • 現在此API依然不能使用,需要點選點選建立憑據按鈕,按照提示流程一直走到最後,生成憑據,主要包含兩個資訊clientID和client金鑰。如果想要看詳細步驟,參考這裡

    • 這個流程裡需要注意的點是,設定JavaScript授權域和授權回撥URL,因為我們現在建立的是一個開發專案,兩者分別設為 http://localhost:5000和http://localhost:5000/*
    • clientID: 用於生成登陸用的URL
    • clientSecrect: 用於證明該APP是否有權訪問token
  • 接下來要把剛才生成的clientID和clientSectrect,傳入Google OAuth模組中。注意,clientSecrect不能公佈,謹慎起見clientID也應該保密。所以我們不希望別人通過檢視原始碼的形式獲取這兩個值。目前我們先通過不提交這部分程式碼的形式做到隱藏這部分資訊。

  • 建立cong/keys.js,存放clientID和clientSecrect
    module.exports = {
      googleClientId: '1229722414-eeujg12q0q9gvisar.apps.googleusercontent.com',
      googleClientSecret: 'ANPiCt5QFTa'
    };複製程式碼
  • 在.gitignore中寫入keys.js,確保包含敏感資訊的檔案不會被提交
  • 在index.js中,引入keys模組,並將對應的clientKey和clientID傳入GoogleStrategy模組中。
    • 注意這裡還新增了回撥URL。這是因為當使用者點選授權後,Google伺服器會返回一個code到應用的伺服器,那我們伺服器應該如何接收並處理這個伺服器的返回呢。Google伺服器返回給app伺服器資訊,可以看做是一次請求(伺服器不就是用來處理請求的嗎),所以我們必須要指定請求的route是什麼,因此我們需要一個回撥URL的引數,google伺服器會將code拼接到這個URL的引數裡。
    • 這裡還新增了一個回撥函式,所有驗證的目的就是為了拿到token,以便使用者隨後的操作,回撥函式定義了拿到token做什麼。目前僅僅先把token打出來看一下。
      const keys = require('./config/keys');
      passport.use(new GoogleStrategy({
      clientID: keys.googleClientId,
      clientSecret: keys.googleClientSecret,
      callbackURL: '/auth/google/callback'
      }, (accessToken, refreshToken, profile, done) => {console.log(accessToken)}));複製程式碼
  • 最後我們需要新增一個route handler,用以接收使用者login的請求,並進入Google OAuth流程,如下面的程式碼所示。
    • 首先解釋一下程式碼的意思:如果伺服器接收到/auth/google的請求,使用passport啟用Google OAuth的驗證流程,需要獲取的資訊有使用者資料和郵箱。
    • 這裡面的字串'google'看起來很讓人費解,因為在之前的程式碼中我們並沒有任何用這個字串代表Google OAuth strategy的意思。這是passport廣為人詬病的一點。事實上,Google OAuth strategy模組中設定了這一點,也就是說這個模組告訴passport如果passport.authenticate方法第一個引數傳入了google,那麼就採用Google OAuth strategy模組驗證。
app.get(
    "/auth/google",
    passport.authenticate("google", {
        scope: ["profile", "email"]
    })
);複製程式碼
  • 現在啟動我們的本地伺服器,訪問localhost:5000/auth/google,按理應該會彈出google認證的頁面,但是不幸的是並沒有,這時彈出的是一個400頁面,大概的意思是說實際提供的驗證回撥地址和在console.developers.google.com中設定的不一致。還提供了一個連結,直接訪問這個連結就進入了修改驗證回撥URL的頁面。
    • 為什麼會出現這個錯誤頁?還記得之前我們把已獲授權的重定向 URI這一項設為http://localhost:5000/*,事實上這裡需要嚴格匹配。之前在程式碼中我們設定callbackURL/auth/google/callback,所以我們應該在這個修改頁面中將已獲授權的重定向 URI這一項設為http://localhost:5000/auth/google/callback,這樣之後應該就能正常彈出授權頁面了。
    • 為什麼需要回撥驗證URL匹配?我們訪問google伺服器要求提供授權時,提供的引數是clientID,並且明文傳輸。攻擊者拿到clientID,並把redirect_uri改為惡意網站,那麼使用者授權後就肯能會跳轉到惡意網站,並提供所有的授權資訊。顯然,這種情況是堅決不能發生的,所以我們需要在google那邊配置允許的回撥URL,並嚴格匹配。如果不匹配是不會成功回撥的。
  • 點選對應的Google賬戶登陸,會跳到一個錯誤頁顯示Cannot GET /auth/google/callback。我們還沒有設定針對回撥route的handler,所以當然會報錯了。在這個頁面的URL中,會看到一個引數code,這就是在之前流程圖中提到的Google伺服器返回的code。我們app的伺服器拿到code後,就可以通過code再次向Google伺服器發請求,並拿到使用者的資料、郵箱等資訊了。所以接下來需要補上對應的route handler。
    app.get('/auth/google/callback', passport.authenticate('google'));複製程式碼
  • 再次訪問localhost:5000/auth/google,點選賬戶登入,可以看到在啟動server的控制檯中列印出了一坨東西。之前我們在配置passport中傳入了一個回撥函式,在回撥函式中列印出了token。這一坨就是取到的token。
    • 實際上passport在回撥URL的handler中自動將code傳遞給了google伺服器,並換取了token、使用者資訊(資料、郵箱等)。這些資訊時通過函式引數的形式傳遞回來的。 因此,在這之後,那個列印token的函式被呼叫,我們的app可以在這個回撥函式中利用這些資訊做一些不可描述的事情。
    • 在繼續之前,我們可以先把這些返回的資訊列印出來,看看長什麼樣子。修改程式碼,重啟server,重新訪問登陸連線,可以看到控制檯中列印出了token(string)、profile(object)、done(function)。
      • accessToken: app後續訪問使用者資訊的憑證。
      • accessToken過一段時間就會過期,refreshToken會允許我們重新整理得到最新的token。
      • profile:使用者所有的資料。
      • done函式的引數有三個:err(錯誤資訊),user(使用者資訊),info(其他資訊)
    • 為什麼會pending?回撥函式中,我們並沒有給出響應response。
      passport.use(
      new GoogleStrategy(
        {
            clientID: keys.googleClientId,
            clientSecret: keys.googleClientSecret,
            callbackURL: "/auth/google/callback"
        },
        (accessToken, refreshToken, profile, done) => {
            console.log('accessToken', accessToken);
            console.log('refreshToken', refreshToken);
            console.log('profile', profile);
            console.log('done', done);
        }
      )
      );複製程式碼
  • 至此,所有授權的工作(在passport的幫助下)已經完成,接下來是建立使用者資訊到資料庫、登陸完成。

使用nodemon使開發自動化

  • 至此,應該已經厭倦了修改程式碼,重啟server的過程。幸運的是已經有工具使這一切自動化,這個工具就是nodemon
  • npm install --save-dev nodemon
  • 修改package.json
    "scripts": {
      "start": "node index.js",
      "dev": "nodemon index.js"
    },複製程式碼
  • 之後只需要在命令列中輸入npm run dev,就可以啟動伺服器,並且每次修改程式碼儲存後,nodemon都會幫我們自動重啟伺服器了。

    重構目前的程式碼

  • 之前我們把所有的邏輯都寫在index.js檔案中,為了便於維護和迭代,我們把邏輯分散在不同的目錄下。目前我們把邏輯分為三個部分config,routes,services。三個部分的含義如下圖所示。重構之後的目錄如下所示。基本的工作就是把routehandler的邏輯移動到authRoutes.js中,把配置passport的邏輯,移動到passport.js中,然後在兩個檔案中引入依賴的包或者其他模組。再在index中引入這兩個檔案。
    ├── config
    │   └── keys.js
    ├── index.js
    ├── package-lock.json
    ├── package.json
    ├── routes
    │   └── authRoutes.js
    └── services
      └── passport.js複製程式碼

  • routes/authRoutes.js
const passport = require('passport');
module.exports =  (app) => {
    app.get(
        "/auth/google",
        passport.authenticate("google", {
            scope: ["profile", "email"]
        })
    );
    app.get("/auth/google/callback", passport.authenticate("google"));
}複製程式碼
  • servics/passport.js
const passport = require('passport');
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const keys = require("../config/keys");

passport.use(
    new GoogleStrategy(
        {
            clientID: keys.googleClientId,
            clientSecret: keys.googleClientSecret,
            callbackURL: "/auth/google/callback"
        },
        (accessToken, refreshToken, profile, done) => {
            console.log('accessToken', accessToken);
            console.log('refreshToken', refreshToken);
            console.log('profile', profile);
            console.log('done', done);
        }
    )
);複製程式碼
  • index.js
    const express = require("express");
    const app = express();
    require('./services/passport');
    require('./routes/authRoutes')(app);
    app.get("/", (req, res) => {
      res.send({ hi: "there" });
    });
    )
    const PORT = process.env.PORT || 5000;
    app.listen(PORT);複製程式碼

next section

相關文章