「Googleでログイン」を実装してみた(その1)

タム

2024.03.22

12

こんにちは、タムです。

今回は表題の通り、Googleでログインを実装してみたのでご紹介です。

フロントはSSGで構築してみました。

成果物

たったのこれだけです。

設計

インフラ

今回は以下のようなインフラ構成で実現しました。


ざっくりと説明するとフロントエンドがCloudFront + S3で静的サイト配信しJSで認可コードを取得、

バックエンドがAPI Gateway + Lambdaでフロントで取得した認可コードを使ってトークン取得を取得します。

フロー図

図にするとこんな感じです。



  1. アプリのURLにアクセスするとCloudFrontからJSなどの静的コンテンツが返される
  2. ブラウザ上のJSから、Googleの認可エンドポイントにアクセスする
    1. その際、リクエストにクライアントIDとコールバックURLを含める
  3. Googleの認可画面が表示されるので、ユーザが(ログインしていなければログインした上で)認可を行う
  4. 認可エンドポイントにアクセス時に渡していたコールバックURLにリダイレクトする
    1. その際、認可コードを受け取る
  5. ブラウザ上のJSから、API Gatewayに対してトークン取得リクエストを送る
    1. その際、リクエストに認可コードを含める
  6. API Gatewayからトークンエンドポイントにアクセスし、アクセストークンやIDトークンなどを受け取る
  7. ブラウザ上のJSから、Googleのuserinfoエンドポイントにアクセスしユーザ情報を取得する
    1. その際、リクエストにアクセストークンを含める

セキュリティ的にどうなのか考えてみる

まず考えられるのは認可コードをバックエンドに送信していますが、

このバックエンドはSSGからのリクエストを受け付ける必要があり

パブリックアクセスを許容する必要があるので、どこからでもアクセスできてしまいます。

そうすると攻撃者が総当り的に認可コードを大量に送ると、

ユーザのアクセストークンを奪うことができてしまう可能性があるかなと思います。

当然CORSは設定していますが、APIを直で叩く場合は関係ないです。

ただ少なくとも使用済みの認可コードは使えなくなるので、

実際にその攻撃が成功する可能性は極めて低いと思います。


他には、同じくフロント・バック通信のところで、リクエスト・レスポンスを

平文でやり取りしているため、万が一通信が傍受されると

同様にアクセストークンが奪われる可能性がありそうです。

公開鍵暗号を使おうとしても結局フロントがSSGなので

秘密鍵を安全に保管することができないと思います。

ただ、Googleからはアクセストークンがそのまま送られてくるわけで、

お門違いな心配をしているような気もします。

ネクストアクション

セキュリティの点はすぐに結論がでなそうなのでじっくり考えようと思います。

その他で今後やってみたいこととしては、

  • インプリシットフローを試す
  • 保護対象リソースを構築して実際にアプリ固有のユーザ情報を取得する
  • 認可スコープを増やしてGoogleカレンダーなどにアクセスしてみる

このあたりが実装できたらまた記事にしたいと思います。

(おまけ)実装

フロントエンド

フロントは個人的にシンプルで好きなsvelteを使っています。


src/routes/+page.svelte

<script lang="ts">
    import axios from "axios";

    async function login() {
        // ディスカバリドキュメントを参照
        const discoverRes = await axios.get('https://accounts.google.com/.well-known/openid-configuration');
        sessionStorage.setItem('discoverDoc', JSON.stringify(discoverRes.data));

        // 認可エンドポイントへのリクエストパラメータ設定
        const params = {
            response_type: 'code',
            client_id: 'XXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com',
            scope: 'openid profile email',
            redirect_uri: 'XXXXXXXXXXXXXXXXXXXXXXXXXX/callback',
            state: Math.random().toString(32).substring(2),
            nonce: Math.random().toString(32).substring(2)
        };

        sessionStorage.setItem('state', params.state);
        sessionStorage.setItem('nonce', params.nonce);

        // 認可エンドポイントへ遷移
        const href = `${discoverRes.data.authorization_endpoint}?response_type=${params.response_type}&client_id=${params.client_id}&scope=${params.scope}&redirect_uri=${params.redirect_uri}&state=${params.state}&nonce=${params.nonce}`;
        location.href = href;
    }
</script>

<h1>ログイン</h1>
<p><button on:click={login}>Googleでログイン</button></p>


src/routes/callback/+page.svelte

<script lang="ts">
    import { error } from '@sveltejs/kit';
    import axios, { type AxiosResponse } from 'axios';
    import { onMount } from 'svelte';
    let queryParams;
    let userInfoRes: AxiosResponse;

    onMount(async () => {
        // コールバックエンドポイントにリダイレクトしてきた際のクエリパラメータを取得
        const params = new URLSearchParams(window.location.search);
        queryParams = Object.fromEntries(params.entries());

        // 送られてきたstateが一致しているか確認
        const sessionState = sessionStorage.getItem('state');

        if (queryParams.state != sessionState) {
            error(401, { message: 'Invalid state parameter.' });
        }

        // バックエンドからトークン取得
        const res = await axios.post('https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/get_token', {
            code: queryParams.code,
        });

        // userinfoエンドポイントにアクセス
        const discoverDoc = JSON.parse(sessionStorage.getItem('discoverDoc') ?? '');
        userInfoRes = await axios.get(discoverDoc.userinfo_endpoint, {headers: {Authorization: `Bearer ${res.data.access_token}`}});
        console.log(userInfoRes);
    });
</script>

{#if userInfoRes}
    <h1>{userInfoRes.data?.name}</h1>
    <img src={userInfoRes.data?.picture} alt="facePicture" />
{:else}
    <h1>ログインしています...</h1>
{/if}


バックエンド

バックエンドは個人的に好きなScalaとかを使いたかったのですがCI/CDの構築に詰まったので一旦安定のpython(Chalice)で作りました

from chalice import Chalice, Response, CORSConfig
import logging
import requests
import boto3
import urllib

app = Chalice(app_name="XXXXXXXXXXXXXXXXXX")
app.log.setLevel(logging.DEBUG)

ssm = boto3.client("ssm")

CLIENT_ID = ssm.get_parameter(Name="/google_oauth/client_id")["Parameter"]["Value"]
CLIENT_SECRET = ssm.get_parameter(Name="/google_oauth/client_secret")["Parameter"]["Value"]
REDIRECT_URI = ssm.get_parameter(Name="/google_oauth/redirect_uri")["Parameter"]["Value"]


@app.route("/get_token", methods=["POST"], cors=CORSConfig(allow_origin="https://XXXXXXXXXXXXXXXXXXXXXXXXXXX"))
def index():
    req = app.current_request.json_body or {}
    if "code" not in req:
        return Response(body={"message": "server error"}, status_code=500)

    body = {
        "code": req["code"],
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "redirect_uri": REDIRECT_URI,
        "grant_type": "authorization_code",
    }
    encoded = urllib.parse.urlencode(body)
    res = requests.post(
        "https://oauth2.googleapis.com/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data=encoded,
    )
    if res.status_code >= 300:
        app.log.error(res.json())
        return Response(body={"message": "server error"}, status_code=500)

    return res.json()


この記事をシェアする