# 第三方登入 {%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); }); ```