Google Cloud Runで動くNode.jsアプリケーション(4)Googleアカウント認証
Posted: 2020-07-30
- ローカル開発環境でNode.jsアプリケーションを作成する
- アプリケーションをコンテナ化し、Cloud Runにデプロイする
- Gitからの継続的デプロイを設定する
- Cloud Translation APIに接続する
- Cloud SQL上のMySQLデータベースに接続する
- Googleアカウント認証を追加する
- フロントエンド部分をFirebase Hostingに移行する
サンプルアプリケーションの概要
「4ヶ国語対応単語帳アプリケーション」は次の3つの画面から構成されています。
1. 一覧画面
単語一覧をデータベースから取得して表示します。学習済みのものについては左上にチェックマークを表示します。
2. 登録・編集画面
単語を新規登録または編集します。テキストボックスの右側の翻訳ボタンを押すと、その言語から他の3言語に自動翻訳されます。
3. チェック画面
単語を表示します。学習できたと感じたら「覚えた!」ボタンを押します。すると左上に青いチェックマークが表示されます。データベース上はdoneカラムの値が1に更新されます。
フロントエンドはReactで実装し、バックエンドのデータベース処理と翻訳APIの処理はExpress.jsで実装しています。
この時点(Googleアカウント認証を追加する前)のソースコードは github.com/ishidait/cloudrun-words/tree/blog3 から参照できます。
まだユーザー認証機能が無いのでこのままだと誰でもすべてのデータにアクセス出来てしまいます。これでは使えないので、Googleアカウント認証を追加します。
その後、ログインユーザーごとにそれぞれ自分が登録したデータにしかアクセスできないようにしましょう。
データベースへのカラム追加
まずはどのユーザーが登録した単語かを区別できるように、wordsテーブルにuser_idカラムを追加します。またuser_idカラムにインデックスを付加しておきます。
# ADD user_id column and index
ALTER TABLE `words`.`words`
ADD COLUMN `user_id` VARCHAR(100) NULL AFTER `done`,
ADD INDEX `idx_user_id` (`user_id` ASC);
6. Googleアカウント認証を追加する
GCPプロジェクトの設定
次のドキュメントを参考にして進めていきます。
Integrating Google Sign-In into your web app
GCP管理画面の「APIとサービス」から「認証情報」画面を開き、「OAuthクライアントID」を新規作成します。
「承認済みのJavaScript生成元」欄にローカル開発環境で動かす場合のURLとCloud Run上のデプロイ先URLを入れておきます。
フロントエンドアプリケーションの変更
次に、src/client/index.htmlにGoogle API Client Libraryを読み込むためのscriptタグを追加します。
<html lang="ja">
<head>
...
<script src="https://apis.google.com/js/platform.js" defer></script>
<script src="index.js" defer></script>
</head>
<body>
...
scriptタグ内にasync属性が付いているとindex.jsとどちらが先に実行されるかが保証されなくなるので、async属性は削除してdefer属性のみを付けています。またindex.jsを読み込んでいるscriptタグの方にもdefer属性を追加しています。両方のscriptタグにdefer属性を付けることによって実行順序が保証されます。
src/client/google-auth.jsを追加。
GOOGLE_CLIENTIDという環境変数を使ってGoogle API Client Libraryの初期化をおこなっています。 init, signIn, signOutの3つの関数をエクスポートしてReact側からGoogle認証の機能を使えるようにしています。
const config = {
clientId: process.env.GOOGLE_CLIENTID,
scope: 'profile email',
};
export function init(onInit) {
if (!gapi) {
throw new Error('Google API SDK is not loaded.');
}
gapi.load('client:auth2', async () => {
try {
await gapi.client.init(config);
const user = gapi.auth2.getAuthInstance().currentUser.get();
onInit(user);
} catch (error) {
onInit(null, error);
}
});
}
export async function signIn() {
return gapi.auth2.getAuthInstance().signIn();
}
export async function signOut() {
return gapi.auth2.getAuthInstance().signOut();
}
src/client/auth-state.jsxを追加。
AuthStateContextというコンテキストを作成し、カスタムプロバイダ内でGoogle認証の初期化処理を呼んでいます。内部的にはReact.useReducerを使ってログイン状態を管理しています。
またuseAuthStateフックをエクスポートしてアプリケーションのどこからでもログイン・ログアウト処理を呼び出せるようにしています。
GitHub: src/client/auth-state.jsx
src/client/SignInOutButton.jsxを追加。
上で作成したuseAuthStateフックを使ってログイン・ログアウト処理を実行するリンクを返すコンポーネントです。
import React from 'react';
import { useAuthState } from './auth-state';
export function SignInOutButton() {
const [state, actions] = useAuthState();
const { isSignedIn } = state;
const { signIn, signOut } = actions;
if (isSignedIn === undefined) return null;
return isSignedIn ? (
<a onClick={signOut} className="sign-out">ログアウト</a>
) : (
<a onClick={signIn} className="sign-in">ログイン</a>
);
}
src/client/index.jsを変更。
アプリケーションのコンポーネントツリーのルートでAuthProviderでAppコンポーネントを挟むことによってアプリケーション全体で認証状態にアクセスすることを可能にしています。
...
render(
<AuthProvider>
<App />
</AuthProvider>,
document.getElementById('root')
);
...
src/client/App.jsxを変更。
前回まではindex.htmlで静的に描画していたヘッダやフッタ部分についてもApp配下のコンポーネントとして描画するように変更しています。これはヘッダ内でログイン・ログアウトボタンやユーザー名の表示を動的におこなえるようにするためです。
また、useAuthStateフックから返されるオブジェクトからidTokenを取得しています。
const [authState] = useAuthState();
const { isSignedIn, idToken, userName } = authState;
GitHub: src/client/App.jsx
このidTokenを下のapi.jsの各メソッドに渡すことでバックエンド(Express.js)側でログインユーザーを識別できるようになります。
src/client/api.jsを変更。
getWords, saveWord, updateDone, deleteWordの各関数でidTokenを受け取り、それをリクエストヘッダに Authorization: `Bearer ${idToken}`
という形で付加してバックエンド側に送っています。
const config = {
API_URL: process.env.WORDS_API_URL || 'http://localhost:8080',
};
async function executeApi(path, { method = 'get', body, idToken }) {
const response = await fetch(`${config.API_URL}${path}`, {
method,
mode: 'cors',
cache: 'no-cache',
headers: new Headers({
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
}),
body: body ? JSON.stringify(body) : undefined,
});
const result = await response.json();
return result;
}
export async function getWords(idToken) {
const result = await executeApi('/words', { idToken });
if (result.status !== 'ok') {
throw new Error(`${result.status} ${result.data}`);
}
return result.data;
}
...
GitHub: src/client/api.js
バックエンド側の変更
idTokenの検証に使うため、google-auth-libraryをインストールします。
> npm i google-auth-library
src/auth-middleware.jsを追加。
クライアント側から送られてきたAuthorizationヘッダーを検証して不正であれば401エラーを返すミドルウェアです。
idTokenの検証に成功すれば、そこに含まれる情報からGoogleアカウント単位で一意に付与されるユーザーIdを取り出してreq.userIdプロパティにセットしています。
const { OAuth2Client } = require('google-auth-library');
const CLIENT_ID = process.env.GOOGLE_CLIENTID;
const client = new OAuth2Client(CLIENT_ID);
async function verify(token) {
try {
const ticket = await client.verifyIdToken({
idToken: token,
audience: CLIENT_ID,
});
const payload = ticket.getPayload();
const userId = payload['sub'];
return userId;
} catch (error) {
console.log(error);
return null;
}
}
function sendAuthError(res) {
res.status(401);
res.send('Auth error');
}
async function auth(req, res, next) {
const authHeader = req.get('Authorization');
if (!authHeader) return sendAuthError(res);
const idToken = authHeader.split(' ')[1];
if (!idToken) return sendAuthError(res);
const userId = await verify(idToken);
if (!userId) return sendAuthError(res);
req.userId = userId;
next();
}
module.exports = auth;
src/index.jsを変更。
各APIのメソッド単位でauthミドルウェアを使って正当なidTokenが送られてきているか検証するように変更します。
idTokenが正当なものであればreq.userIdにユーザーIdがセットされているので、その値を使ってデータベースから該当ユーザーの情報だけを抽出または更新するようにします。
...
const auth = require('./auth-middleware');
...
// 単語一覧
app.get('/words', auth, async (req, res) => {
const userId = req.userId;
const words = await knex.select('*').from('words').where({ user_id: userId }).orderBy('id');
res.json({ status: 'ok', data: [...words] });
});
// 単語追加
app.post('/words', auth, async (req, res) => {
...
GitHub: src/index.js
これで下の画像のように未認証のときはログインを求められるようになりました。
ログインすると自分が登録した単語・文のみが表示されるようになっています。
参考URL:
Cloud Run local development - YouTube