---
# System prepended metadata

title: 第三方登入

---

# 第三方登入
{%hackmd BJrTq20hE %}
## 取得API服務
- Google：[OAuth2.0](https://console.cloud.google.com/home/dashboard?project=wide-episode-333802&hl=zh-TW)
Google Cloud Platform > 左邊選單-API與服務 > 憑證 > 建立憑證 > OAuth2.0 > URI設定你要導向的網址(ex:`http://localhost:3000/auth/google/callback`)
- Github：[github app](https://github.com/settings/apps/onedog-dev)
- [Facebook](#FB-api登入)
### Free Webhook Url：
[url](https://webhook.site/#!/366016ad-6216-43c3-ab9b-5094baedf7a2/a329bea8-6b53-48c1-8283-f429420ebc3a/1)：`https://webhook.site/366016ad-6216-43c3-ab9b-5094baedf7a2`

## 使用passportjs套件
[passport完整教學](https://medium.com/%E9%BA%A5%E5%85%8B%E7%9A%84%E5%8D%8A%E8%B7%AF%E5%87%BA%E5%AE%B6%E7%AD%86%E8%A8%98/%E7%AD%86%E8%A8%98-%E9%80%8F%E9%81%8E-passport-js-%E5%AF%A6%E4%BD%9C%E9%A9%97%E8%AD%89%E6%A9%9F%E5%88%B6-11cf478f421e)
尋找策略套件： https://www.passportjs.org/packages/
`npm i passport express-session` (passport為純認證模組)
`npm i passport-google-oauth20` google認證套件
- 載入設定
    ```javascript=
    // \app.js
    const passport = require('passport') //入第三方套件庫
    const session = require('express-session') //讀寫session空間

    // Passport config，兩種方式皆可
    require('./config/passport')(passport) // 引入方式一
    const passportSetup = require("./config/passport"); // 引入方式二(名稱自訂)

    // Sessions設定
    app.use(
      session({
        secret: 'keyboard cat',
        resave: false,
        saveUninitialized: false,
        store: MongoStore.create({ //將session存到MongoDB
          mongoUrl: process.env.MONGO_URI,
          dbName: "storybooks",
          stringify: false,
        }),
      })
    )

    // Passport middleware
    app.use(passport.initialize())
    app.use(passport.session())
    ```
    (PS：將session存到DB是避免伺服器重啟時會遺失session，導致需要再登入一次)
    Middleware：
    -   `passport.initialize()`：確認 passport.user 是否已存在，若沒有則初始化一個空的。
    -   `passport.session()`：用以處理 Session。若有找到 passport.user，則判定其通過驗證，並呼叫 deserializeUser()。
    -   `passport.authenticate()`：用以驗證使用者。可設定要採用的 Strategy、驗證成功與失敗導向的頁面。
    -   `ensureAuthenticated()`：客製化 middleware，用以確認其為「已驗證」的狀態，並導向頁面。

    Method：序列化與反序列化
    Session知識點：[PJCHENder](https://pjchender.dev/npm/npm-passport/#%E8%A7%A3%E6%9E%90-session)
    > 序列化（serialize）簡單來說就是把「物件」轉換成可被儲存在儲存空間的「資料」的這個過程，例如把 JavaScript 中的物件透過 `JSON.stringify()` 變成字串，就可以存放在儲存空間內；而反序列化則反過來是把「資料」轉換成程式碼中的「物件」，例如把 JSON 字串透過 `JSON.parse()` 轉換成物件。
    -   `serializeUser()`：可設定要將哪些 user 資訊，儲存在 Session 中的 passport.user。（如 user._id）
    -   `deserializeUser()`：可藉由從 Session 中獲得的資訊去撈該 user 的資料。
    > 預設 Passport 會把整個 user 實例都存放在 session 中，但這麼做佔用了 session 許多不必要的空間。為解決這樣的問題，Passport 可以透過序列化（serialize）的方式，只保存 UserId 在 session 中，當有需要更多使用者資訊時，再透過反序列化（deserialize）的方式，根據 User ID 把整個 user 物件實例取出。
- 設定passport參數：[sample1](https://github.com/twozwu/node-app-storybooks/blob/main/config/passport.js)
    設定策略 Strategies：
    ```java=
    passport.use(new <Strategies>({設定參數}), (<verify callback>))：
    //以此使用 Strategies：1.new 一個想使用的 Strategy 2.設定參數 3.返回驗證資料的 callback。
    ```
    ```javascript=
    //config/passport.js
    const GoogleStrategy = require("passport-google-oauth20").Strategy;
    const mongoose = require("mongoose");
    const User = require("../models/User");

    module.exports = function (passport) {
      passport.use(
        new GoogleStrategy(
          {
            clientID: process.env.GOOGLE_CLIENT_ID,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET,
            callbackURL: "/auth/google/callback",
          },
          async (accessToken, refreshToken, profile, done) => {
            // console.log(profile);
            const newUser = {
              googleId: profile.id,
              displayName: profile.displayName,
              firstName: profile.name.givenName,
              lastName: profile.name.familyName,
              image: profile.photos[0].value,
            };

            try {
              let user = await User.findOne({ googleId: profile.id }); //查資料庫有無此用戶

              if (user) {
                done(null, user);
              } else {
                user = await User.create(newUser); //如果沒有就新增用戶
                done(null, user);
              }
            } catch (err) {
              console.error(err);
            }
          }
        )
      );

      //可設定要將哪些 user 資訊，儲存在 Session 中的 passport.user。（如 user._id）(被動觸發)
      passport.serializeUser((user, done) => {
        // 只將用戶 id 序列化存到 session 中
        done(null, user.id);
      });

      //從 Session 中獲得的資訊去撈該 user 的資料(被動觸發)
      passport.deserializeUser((id, done) => {
        User.findById(id, (err, user) => done(err, user));
      });
    };
    ```
- routes路徑設定(Google認證)：
    ```javascript=
    //routes/auth.js
    const express = require('express')
    const passport = require('passport')
    const router = express.Router()

    // @desc    Auth with Google
    // @route   GET /auth/google
    router.get('/google', passport.authenticate('google', { scope: ['profile'] })) //呼叫google認證

    // @desc    Google auth callback
    // @route   GET /auth/google/callback
    router.get(
      '/google/callback',
      passport.authenticate('google', { failureRedirect: '/' }), //如果認證失敗會回到首頁
      (req, res) => {
        res.redirect('/dashboard') //如果認證成功則回到儀表板
      }
    )

    // @desc    Logout user
    // @route   /auth/logout
    // passport 0.5.0版
    router.get('/logout', (req, res) => {
      req.logout() //passport內建函式，將使用者存在 session 的資料作廢
      res.redirect('/')
    })

    // passport 0.6.0版，安全性修正
    router.get("/logout", (req, res) => {
        req.logout((err) => {
            if (err) { return next(err); }
            res.redirect('/');
        });
    });

    module.exports = router
    ```
    passport 0.6.0版，安全性修正[原因](https://stackoverflow.com/questions/72336177/error-reqlogout-requires-a-callback-function)
- 本地認證(查看session是否已登入過)
`isAuthenticated()` 是一個在 `req` 物件上可以使用的 passport 方法 — 來得知使用者是否已經通過驗證。

    ```javascript=
    //建立middleware，/middleware/auth.js
    module.exports = {
        ensureAuth: function (req, res, next) {
          if (req.isAuthenticated()) { //驗證session是否有值(passport方法)
            return next()
          } else {
            res.redirect('/')
          }
        },
        ensureGuest: function (req, res, next) {
          if (req.isAuthenticated()) {
              res.redirect('/dashboard');
            } else {
              return next();
          }
        },
      }
    ```
    加到路徑中間件當作 Route Protection
    ```javascript=
    //routes/index.js
    const express = require("express");
    const router = express.Router();
    const { ensureAuth, ensureGuest } = require("../middleware/auth");

    router.get("/", ensureGuest, (req, res) => {
      res.render("login", { layout: "login" }); //渲染網頁
    });

    router.get("/dashboard", ensureAuth, (req, res) => {
      console.log(req.user);
      res.render("dashboard");
    });

    module.exports = router;
    ```
### 前端使用：
直接原地開啟api連結，會自動導向該SSO的登入頁面
```jsx=
  const google = () => {
    window.open("http://localhost:5000/auth/google", "_self");
  };

  const github = () => {
    window.open("http://localhost:5000/auth/github", "_self");
  };

  const facebook = () => {
    window.open("http://localhost:5000/auth/facebook", "_self");
  };
```
## Google
自定義登入按鈕：[link](https://developers.google.com/identity/sign-in/web/build-button#customizing_the_automatically_rendered_sign-in_button_recommended)
gapi的各種參數：[gapi](https://developers.google.com/identity/sign-in/web/reference)
[vue元件引入外部js方法](https://juejin.cn/post/6970281486469562375)
### 手動設定
1. 加載Google平台庫
    ```javascript=
    <script src="https://apis.google.com/js/platform.js" async defer></script>
    ```
2. 指定您的應用的客戶端ID
    ```htmlembedded=
    <meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
    ```
3. 添加登入按鈕
    ```htmlembedded=
    <button id="loginBtn">登入</button>
    <button @click="Logout">登出</button>
    ```
4. 部屬coding
    ```javascript=
    <script setup>
      import { onMounted } from "vue";
      let googleUser = {};
      let googleLoginBtn = "";
      let auth2;

      onMounted(() => {
        googleLoginBtn = document.getElementById("loginBtn");
        gapi.load("auth2", function () {
          //初始化GoogleAuth對象
          auth2 = gapi.auth2.init({
            client_id:
              "74791824651-d7f52jtbe5u7dd5jpfg0pqjcj3l4oaob.apps.googleusercontent.com",
            cookiepolicy: "single_host_origin",
          });
          // 綁定登入按鈕
          attachSignin(googleLoginBtn);
        });
      });

      function attachSignin(element) {
        // auth2.attachClickHandler('signin-button', {}, onSuccess, onFailure);   需要四個參數
        auth2.attachClickHandler(
          element,
          {},
          function (googleUser) {
            // 取得token
            const id_token = googleUser.getAuthResponse().id_token;
            console.log(id_token);
            // 取得個人資料
            const profile = googleUser.getBasicProfile();
            console.log("ID: " + profile.getId()); // Do not send to your backend! Use an ID token instead.
            console.log("Name: " + profile.getName());
            console.log("Image URL: " + profile.getImageUrl());
            console.log("Email: " + profile.getEmail()); // This is null if the 'email' scope is not present.
          },
          function (error) {
            console.log(error);
          }
        );
      }

      function logout() {
        // auth2.signOut();
        auth2.disconnect();
      }
    </script>
    ```
## FB api登入
[FB develope後台](https://developers.facebook.com/apps/?show_reminder=true)，查看id、secret請到fb app>設定>基本資料
[FB api 文件](https://developers.facebook.com/docs/facebook-login/web)
[參考文件 WFU BLOG](https://www.wfublog.com/2018/12/fb-api-log-in-out-button-example.html)
[超完整進階範例](https://jasonwatmore.com/post/2020/09/26/vuejs-facebook-how-to-use-the-facebook-sdk-in-an-vue-app)
### ngrok [下載](https://ngrok.com/)
使用localhost測試注意：因為FB硬性要求要用https協定，所以用vscode本機測試會無法連線，所以需要使用ngrok幫你的本機開後門，然後再用ngrok產生的連結來測試。
1. 將下載的主程式放到網頁資料夾
2. 使用：`./ngrok http <port>`，例如`./ngrok http 3000`即會對應到`http://localhost:3000`
3. 將產生的臨時網址貼到網址列測試，如：`https://71dd-1-170-20-47.ngrok.io`。
fb登入設定參考：
![](https://i.imgur.com/ZNwYgci.png)
### 登入登出按鈕：
```htmlembedded=
<template>
  <!-- 原生按鈕 -->
  <fb:login-button scope="public_profile,email" autologoutlink="true" onlogin="checkLoginState();"></fb:login-button>
  <!-- 自訂按鈕 -->
  <button id="FB_login" class="btn btn-large btn-primary" @click="fbLogin">FB 登入</button>
  <button id="FB_logout" class="btn btn-large btn-warning" @click="fbLogout">FB 登出</button>
  <!-- 顯示狀態 -->
  目前狀態：<span id="FB_STATUS_1"></span>
</template>
```
### 載入FB SDK
```javascript=
<script setup>
    // 初始化
    window.fbAsyncInit = function () {
      FB.init({
        appId: '{app-id}', // 填入 FB APP ID
        cookie: true,
        xfbml: true,
        version: 'v3.2'
      });
      FB.getLoginStatus(function (response) {
        //statusChangeCallback(response);
        FB.AppEvents.logPageView();    
      });
    };
    // 處理各種登入身份
    function statusChangeCallback(response) {
      console.log(response);
      const target = document.getElementById("FB_STATUS_1");let html = "";
      // 登入 FB 且已加入會員
      if (response.status === 'connected') {
        html = "已登入 FB，並加入 友廷等公車應用程式<br/>";
        FB.api('/me?fields=id,name,email', function (response) {
          console.log(response);
          html += "會員暱稱：" + response.name + "<br/>";
          html += "會員 email：" + response.email + "<br/>";
          html += "會員 uid :" + response.id;
          target.innerHTML = html;
        });
      }
      // 登入 FB, 未偵測到加入會員
      else if (response.status === "not_authorized") {
        target.innerHTML = "已登入 FB，但未加入 友廷等公車應用程式 應用程式";
      }
      // 未登入 FB
      else {
        target.innerHTML = "未登入 FB";
      }
    }
    // 查看登入狀態
    function checkLoginState() {
      FB.getLoginStatus(function (response) {
        statusChangeCallback(response);
      });
    }
    // 載入 FB SDK
    (function (d, s, id) {
      var js, fjs = d.getElementsByTagName(s)[0];
      if (d.getElementById(id)) return;
      js = d.createElement(s);
      js.id = id;
      js.src = "https://connect.facebook.net/zh_TW/sdk.js";
      fjs.parentNode.insertBefore(js, fjs);
      console.log('已載入');
    }(document, 'script', 'facebook-jssdk'));

    function fbLogin() {
      // 進行登入程序
      FB.login(function (response) {
        statusChangeCallback(response);
      }, {
        scope: 'public_profile,email'
      });
    }

    function fbLogout() {
      // 登出
      FB.logout(function (response) {
        statusChangeCallback(response);
      });
    }
</script>
```
## Line login api
[開發者申請教學](https://www.cyberbiz.io/support/?p=675)
[開發者後台](https://developers.line.biz/console/)
[開發者文件](https://developers.line.biz/zh-hant/docs/line-login/integrate-line-login/#making-an-authorization-request)
[參考教學](https://www.letswrite.tw/line-login-code/)
1. 轉登入畫面取得授權碼：
    ```javascript=
    <template>
      <button id="lineLoginBtn" @click="lineLogin">LINE 登入</button>
    </template>

    <script setup>
    function lineLogin() {
      let client_id = '1656689693';   //你的Channel ID
      let redirect_uri = 'http://localhost:3000';   //網站域名，需與Callback URL一樣
      let link = 'https://access.line.me/oauth2/v2.1/authorize?';
      link += 'response_type=code';
      link += '&client_id=' + client_id;
      link += '&redirect_uri=' + redirect_uri;
      link += '&state=login'; //自己設定的驗證碼
      link += '&scope=openid%20profile'; //你想取得的資料(openid profile email)
      window.location.href = link;
    }
    </script>
    ```
    登入成功後會轉址回來，取得token ( 會放在網址code裡面 )
    ```htmlembedded=
    http://localhost:3000/?code=s3Ak10doJShqg2ef1T5F&state=login
    ```
2. 回傳授權碼取得id_token：
    ps：授權碼只能使用一次，用於取得 Access token 的授權碼，有效期間為 10 分鐘。
    ```javascript=
    function getIdToken() {
      // 取得網址&取出code參數
      const paramObj = new URL(location.href).searchParams;
      const authCode = paramObj.get("code");
      const lineSecret = "253500b9c8e4d24083333b68c11b1c46"; //你的Channel secret

      const params = {
        grant_type: "authorization_code",
        code: authCode,
        redirect_uri: redirect_uri,
        client_id: client_id,
        client_secret: lineSecret,
      };
      // 物件轉字串方法一：
      const paramStr = Object.keys(params)
        .map((k) => `${k}=${encodeURIComponent(params[k])}`)
        .join("&");
      //物件轉字串方法二：
      const urlParams = new URLSearchParams(window.location.search);
      const options2 = Qs.stringify({
        // POST的參數  用Qs是要轉成form-urlencoded 因為LINE不吃JSON格式(需另裝Qs套件)
        grant_type: "authorization_code",
        code: urlParams.get("code"),
        redirect_uri: process.env.VUE_APP_LINE_REDIRECT_URL,
        client_id: process.env.VUE_APP_LINE_CHANELL_ID,
        client_secret: process.env.VUE_APP_LINE_CHANELL_SECRET,
      });

      const option = {
        method: "post",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: paramStr, //轉完的字串放入body
      };

      fetch("https://api.line.me/oauth2/v2.1/token", option)
        .then((r) => {
          if (r.status !== 200) {
            throw r.statusText;
          }
          return r.json(); //轉成json物件才能讀取
        })
        .then((json) => {
          console.log(json);
          document.querySelector("#log").textContent = JSON.stringify(
            json, //value
            null, //不用篩選(即全部生成)
            4 //插入縮排空格
          );
        });
      //返回結果： {
      //     "access_token": "eyJhbGciOiJIUzI1NiJ9.pEVUU...",
      //     "token_type": "Bearer",
      //     "refresh_token": "kbHR9KoXjEM0KiKX38Ke",
      //     "expires_in": 2592000,
      //     "scope": "profile openid",
      //     "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1N..."
      //     }
    }
    </script>
    ```
3. 使用上面的id_token取得個人資料：

    ```javascript=
    function getProfile() {
      const params = {
        id_token: idToken,
        client_id: client_id,
      };
      const paramStr = Object.keys(params)
        .map((k) => `${k}=${params[k]}`)
        .join("&");
      console.log(paramStr);
      fetch("https://api.line.me/oauth2/v2.1/verify", {
        method: "post",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: paramStr,
      })
        .then((res) => {
          return res.json();
        })
        .then((json) => {
          console.log(json);
        });
        //取得個資
          // amr: ["linesso"];
          // aud: "1656689693";
          // exp: 1638731034;
          // iat: 1638727434;
          // iss: "https://access.line.me";
          // name: "Vincent（勳）🇦🇺";
          // picture: "https://profile.line-scdn.net/0m0e164fe072514e173728de4b0e95bf4e85818855721f";
          // sub: "U4bd1602bda3dd10f5656e364aed266cf";
    }
    ```
### Line官方帳號連結
`https://line.me/R/ti/p/{lineID}`

直接開啟app連結：`line://ti/p/@{SearchID}`
## cors() 問題設定
[教學說明-youtube](https://www.youtube.com/watch?v=nviGhgtFbRo&ab_channel=WebBASE)
1. 前端：
    fetch：
    ```javascript=
    fetch(`${baseUrl}/auth/login/success`, {
        credentials: "include", // 資格證明
    }).then().then()
    ```
    axios：
    ```javascript=
    const clickHandler = () => {
        const obj = { name: input.current.value };
        const { data } = axios.post("YOUR-BACK-END-URL/new", obj, {
          withCredentials: true,
        });
    };
    ```
1. 後端：
    ```javascript=
    app.use(
      session({
        resave: false,
        saveUninitialized: false,
        secret: "sessionss",
        cookie: {
          maxAge: 1000 * 60 * 60,
          sameSite: "none",
          // httpOnly: false,
          secure: true,
        },
      })
    );


    app.post("/new", async (req, res) => {
        console.log(req.body.name);
        req.session.name = req.body.name;
        res.send({ message: "saved" }).status(201);
    });
    ```