# 第三方登入
{%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登入設定參考:

### 登入登出按鈕:
```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);
});
```