Apps Script用Sheet生成動態網頁(28): 整合Google登入

整合Google登入的方式採用與LINE相同的OAuth 2的機制,可以讓我們基本沿用相同的程式,只是改改置入參數就可以完成整合登入的作業。

設定檔中加入針對Google登入使用的參數,這裡的一些名詞就不改了,方便直接使用LINE登入的code。

官方文件可以參考下面這裡

https://developers.google.com/identity/protocols/oauth2/web-server

1. 後端部份

src/server/settings.js

export const GOOGLE_CONFIG = {
tokenUrl: 'https://oauth2.googleapis.com/token',
callbackUrl: process.env.SERVER_URL,
channelId: process.env.GOOGLE_CLIENT_ID,
channelSecret: process.env.GOOGLE_SECRET,
loginState: process.env.GOOGLE_STATE,
};


在伺服端doGet的Handlers裡頭加入LINE或Google登入的判斷。如果不是這兩者,就要顯示登入失敗的訊息,這裡得寫法不是很好,我之後有空再重構吧!

src/server/handlers.js

...
import GoogleOAuth, { checkState as isGoogleState } from './oauth/google';
...
const Handlers = {
oauth: {
func: arg => {
const { state } = arg;
if (isLineState(state)) return LineOAuth(arg);
if (isGoogleState(state)) return GoogleOAuth(arg);
const template = HtmlService.createTemplateFromFile('failure');
template.baseUrl = '';
template.error = '登入問題';
template.error_description = `未知的登入方法${state}`;
template.loginBy = '未知';
return template.evaluate();
},
immediateRetrun: true,
},


因為之前的登入失敗畫面直接寫LINE,這裡就修改一下template取用loginBy變數

<!DOCTYPE html>
<html>
<head>
<base target="_top" href="<?= baseUrl ?>">
</head>
<body>
<h1><?= loginBy ?>登入失敗</h1>
<p>錯誤:<?= error ?></p>
<p>描述:<?= error_description ?></p>
<a href="exec">點一下回首頁</a>
</body>
</html>

同樣調整LINE變成loginBy

<!DOCTYPE html>
<html>
<head>
<base target="_top" href="<?= baseUrl ?>">
</head>
<body>
<h1><?= loginBy ?>登入成功</h1>
<p>歡迎您:<?= loginName ?>使用<?= loginBy ?> 登入</p>
<p>請點一下<a href="exec">這裡</a>回首頁</p>
<script>
localStorage.setItem('user-token', <?= loginToken ?>);
google.script.history.replace(null, '', ''); // remove params and hash
// remove all scripts
document.querySelectorAll('script').forEach(function (s) {
s.parentNode.removeChild(s)
});
</script>
</body>
</html>


下面就是實作Google登入的程式碼,程式碼是直接拷貝LINE登入的部份,加上以下修改:

  1. 改帶入Google專用的config
  2. 加上checkState函數,用來確認是Google登入的
  3. output部份加上 loginBy 

src/server/oauth/google.js

import { GOOGLE_CONFIG, SERVER_URL } from '../settings';
import { loginByLineId } from '../user';

export const checkState = state => state === GOOGLE_CONFIG.loginState;

const outputFailure = (error, desc) => {
const template = HtmlService.createTemplateFromFile('failure');
template.baseUrl = SERVER_URL;
template.error = error;
template.error_description = desc;
template.loginBy = 'Google';
return template.evaluate();
};

const outputSuccess = ({ token, name, id }) => {
const template = HtmlService.createTemplateFromFile('success');
template.baseUrl = SERVER_URL;
template.loginToken = token;
template.loginName = name;
template.loginUid = id;
template.loginBy = 'Google';
return template.evaluate();
};

const logErrorAndOutput = error => {
Logger.log('logErrorAndOutput', error);
return outputFailure(error.message, JSON.stringify(error));
};

function decodeJwtInObjectForm(jwt) {
const payload = jwt.split('.')[1];
const blob = Utilities.newBlob(
Utilities.base64DecodeWebSafe(payload, Utilities.Charset.UTF_8)
);
return JSON.parse(blob.getDataAsString());
}

const parseLineLogin = json => {
const lineUser = decodeJwtInObjectForm(json.id_token);
const nowTime = Date.now();
if (lineUser.exp > nowTime)
throw new Error(`login token expired, ${lineUser.exp}>=${nowTime}`);
return lineUser;
};

const fetchToken = code => {
const response = UrlFetchApp.fetch(GOOGLE_CONFIG.tokenUrl, {
contentType: 'application/x-www-form-urlencoded',
method: 'post',
payload: {
grant_type: 'authorization_code',
redirect_uri: GOOGLE_CONFIG.callbackUrl,
code,
client_id: GOOGLE_CONFIG.channelId,
client_secret: GOOGLE_CONFIG.channelSecret,
},
});
return JSON.parse(response.getContentText());
};

const OAuth = ({ state, code, error, error_description: errorDesc }) => {
if (error) return outputFailure(error, errorDesc);
const serverState = GOOGLE_CONFIG.loginState;
if (!state || state !== serverState || !code)
return outputFailure(error, errorDesc);
try {
const lineLogin = parseLineLogin(fetchToken(code));
const ourLogin = loginByLineId(lineLogin.sub);
return outputSuccess({
token: ourLogin.token,
id: lineLogin.sub,
name: lineLogin.name,
});
} catch (except) {
return logErrorAndOutput(except);
}
};

export default OAuth;


2. 界面部份

在登入畫面下面再加上一個Google登入,以及Google登入按鈕所需的參數

src/client/demo-bootstrap/pages/Login.jsx

...
const getLineLoginURL = () => {
const LINE_AUTH_URL = 'https://access.line.me/oauth2/v2.1/authorize';
const SCOPE = 'profile%20openid';
const CHANNEL_ID = process.env.LINE_CHANNEL_ID;
const CALLBACK_URL = process.env.SERVER_URL;
const STATE = process.env.LINE_STATE;
const NONCE = process.env.LINE_NONCE;
return `${LINE_AUTH_URL}?response_type=code&client_id=${CHANNEL_ID}&redirect_uri=${CALLBACK_URL}&state=${STATE}&scope=${SCOPE}&nonce=${NONCE}`;
};

const getGoogleLoginURL = () => {
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const SCOPE = 'https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile%20openid';
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const CALLBACK_URL = process.env.SERVER_URL;
const STATE = process.env.GOOGLE_STATE;
const NONCE = process.env.GOOGLE_NONCE;
return `${AUTH_URL}?access_type=offline&include_granted_scopes=true&response_type=code&client_id=${CLIENT_ID}&redirect_uri=${CALLBACK_URL}&state=${STATE}&scope=${SCOPE}&nonce=${NONCE}`;
};

const Login = () => {
...
<LoginForm
onSubmit={onSubmit}
isSubmiting={submiting}
lineLoginUrl={getLineLoginURL()}
googleLoginUrl={getGoogleLoginURL()}
/>
...


src/client/demo-bootstrap/components/LoginForm.jsx

...
const LoginForm = ({
onSubmit,
isSubmiting,
buttonTitle,
confirmPassword,
lineLoginUrl,
googleLoginUrl,
}) => {
...
<Row>
{googleLoginUrl && (
<a href={googleLoginUrl} className="btn btn-outline-secondary">以Google登入</a>
)}
</Row>
...
LoginForm.defaultProps = {
...
googleLoginUrl: null,
};

LoginForm.propTypes = {
...
googleLoginUrl: PropTypes.string,
};


3. 展示



留言