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

タム

2024.03.30

13

こんにちは。タムです。

今回はGoogleでログインの第3回目ということで、課題として上げていた

保護対象リソースの作成を行ったので、実装内容の紹介をさせていただきます。


まず初めに今回の実装の概要ですが、投稿機能を追加しました。

X(Twitter)のように、短いテキストを投稿できるSNSを想定しています。

成果物

短文を投稿するのと自分の投稿を一覧表示するシンプルな画面を作りました。

ちなみに、投稿画面だけちょっといい感じのスタイルになってるのはChatGPTさんに作ってもらったからです。

html+cssだったらかなり上出来でいいなと思いました。

設計

インフラ

追加の要素としては、データ永続化層が必要なので、DynamoDBが増えたというところだけです。

フロー図

認証(IDトークンを取得する)フローについては前回までの内容と同じなので省略します。

認証後のフローとしては、IDトークン・アクセストークンをセッションストレージに保存し、

それを持ってアプリ固有の保護対象リソースにアクセスするAPIを呼び出します。

今回は投稿機能ということで、投稿内容を一覧取得するAPIと、投稿するAPIの2本を作成しました。


以下は投稿時のフロー図です。

認可サーバのユーザ情報取得APIと投稿API、投稿一覧取得APIの計3本のAPIを叩きます。


テーブル設計

Users

  • issuer(パーティションキー)
  • subject(ソートキー)
  • id
  • createdAt
  • updatedAt


Microposts

  • userId(パーティションキー)
  • postedAt(ソートキー)
  • content
  • createdAt
  • updatedAt


Usersの用途としては、IDトークンのissとsubからアプリ固有のidにマッピングするのが目的です。

IDトークンの仕様上、issとsubの組み合わせでグローバルに一意になることが保証されるためです。

こうしておくことで、もし他のIDProviderが増えても問題ない作りになっています。


Micropostsが投稿を保存するためのテーブルです。

主にユーザに紐づく投稿を一覧取得する目的なので、userId, postedAtの複合主キーとしました。

ただ、投稿を一意に指定できるIDもあったほうが良かったかもしれません。

今の形だと、例えば特定の投稿を更新したい場合に、「自分が投稿した投稿日時yyyy-MM-dd hh:mm:ssの投稿を更新してください」

のようなぎこちない指定方法になってしまうためです。

実装

フロントエンド

src/routes/callback/+page.svelte

@@ -1,4 +1,5 @@
 <script lang="ts">
+	import { goto } from '$app/navigation';
 	import { error } from '@sveltejs/kit';
 	import axios, { type AxiosResponse } from 'axios';
 	import { onMount } from 'svelte';
@@ -22,6 +23,10 @@
 			code: queryParams.code,
 		});
 
+		// トークンをセッションストレージに保存
+		sessionStorage.setItem('access_token', res.data.access_token);
+		sessionStorage.setItem('id_token', res.data.id_token);
+
 		// userinfoエンドポイントにアクセス
 		const discoverDoc = JSON.parse(sessionStorage.getItem('discoverDoc') ?? '');
 		userInfoRes = await axios.get(discoverDoc.userinfo_endpoint, {headers: {Authorization: `Bearer ${res.data.access_token}`}});
@@ -32,6 +37,7 @@
 {#if userInfoRes}
 	<h1>{userInfoRes.data?.name}</h1>
 	<img src={userInfoRes.data?.picture} alt="facePicture" />
+	<button on:click={() => goto("/microposts")}>投稿</button>
 {:else}
 	<h1>ログインしています...</h1>
 {/if}


src/routes/microposts/+page.svelte

<script lang="ts">
    import axios, { type AxiosResponse } from 'axios';
    import dayjs from 'dayjs';
    import { onMount } from "svelte";

    let access_token: string;
    let id_token: string;
    let userInfoRes: AxiosResponse;
    let micropostRes: AxiosResponse;
    let content: string;

    onMount(async () => {
        // セッションストレージからトークンを取得
        access_token = sessionStorage.getItem('access_token') ?? '';
        id_token = sessionStorage.getItem('id_token') ?? '';

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

        // 投稿一覧取得APIにアクセス
        micropostRes = await axios.get('https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/microposts', {headers: {Authorization: `Bearer ${id_token}`}});
        console.log(micropostRes);
    });

    async function post() {
        // 投稿
        await axios.post('https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/microposts', {content: content}, {headers: {Authorization: `Bearer ${id_token}`}});
        // リロード
        micropostRes = await axios.get('https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/microposts', {headers: {Authorization: `Bearer ${id_token}`}});
        console.log(micropostRes);
        // インプットを空にする
        content = ''
    }
</script>

<h1>投稿</h1>
{#if userInfoRes && micropostRes}
<div style="max-width: 600px; margin: 20px auto;">
    {#each micropostRes.data as micropost}
    <div style="border-bottom: 1px solid #ccc; padding: 10px 0; display: flex; align-items: center;">
        <img style="width:50px; height:50px; border-radius:50%; margin-right:10px;" src={userInfoRes.data.picture} alt="facePicture">
        <div style="flex-grow: 1;">
            <h3 style="margin:0; font-size:18px;">{userInfoRes.data.name}</h3>
            <p style="margin:5px0; font-size:14px; color:#555;">{micropost.content}</p>
            <p style="margin:5px0; font-size:14px; color:#555;">{dayjs(micropost.postedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
        </div>
    </div>
    {/each}
    <div style="padding: 10px 0; display: flex; align-items: center;">
        <div style="flex-grow: 1;">
            <input style="display: inline-block; width: calc(100% - 6em);" type="text" bind:value={content} />
            <button on:click={post}>投稿</button>
        </div>
    </div>
</div>
{/if}


バックエンド

app.py

@@ -3,6 +3,8 @@ import logging
 import requests
 import boto3
 import urllib
+from chalicelib.models import Microposts
+from chalicelib.utils import login
 
 
 app = Chalice(app_name="XXXXXXXXXXXXXXXXX")
@@ -39,3 +41,32 @@ def index():
         return Response(body={"message": "server error"}, status_code=500)
 
     return res.json()
+
+
+@app.route("/microposts", methods=["POST"], cors=CORSConfig(allow_origin="https://XXXXXXXXXXXXXXXXXXXXXXXXX"))
+def post():
+    try:
+        user = login(app, CLIENT_ID)
+    except Exception:
+        return Response(body={"message": "server error"}, status_code=500)
+
+    req = app.current_request.json_body or {}
+    if "content" not in req:
+        return Response(body={"message": "server error"}, status_code=500)
+
+    micropost = user.post(req["content"])
+    micropost.save()
+
+    return Response(body={"posted_at": micropost.postedAt.isoformat()}, status_code=201)
+
+
+@app.route("/microposts", methods=["GET"], cors=CORSConfig(allow_origin="https://XXXXXXXXXXXXXXXXXXXXXXX"))
+def get_list():
+    try:
+        user = login(app, CLIENT_ID)
+    except Exception:
+        return Response(body={"message": "server error"}, status_code=500)
+
+    posts = Microposts.query(user.id)
+    res = [post.to_simple_dict() for post in posts]
+    return Response(body=res, status_code=200)


chalicelib/utils/login.py

import jwt
from chalice import Chalice
from chalicelib.models import Users


def login(app: Chalice, client_id: str) -> Users:
    if "Authorization" not in app.current_request.headers:
        raise Exception

    id_token = app.current_request.headers["Authorization"].removeprefix("Bearer ")

    jwks_client = jwt.PyJWKClient("https://www.googleapis.com/oauth2/v3/certs")
    try:
        signing_key = jwks_client.get_signing_key_from_jwt(id_token)
        payload = jwt.decode(
            id_token,
            signing_key.key,
            algorithms=["RS256"],
            audience=client_id,
        )
    except Exception as e:
        app.log.error(e)
        raise Exception

    count = Users.count(payload["iss"], Users.subject == payload["sub"])
    if count == 0:
        u = Users(issuer=payload["iss"], subject=payload["sub"])
        u.save()

    return Users.get(payload["iss"], payload["sub"])


一旦app.pyにベタ書きで済ませたかったのですが、Usersのインスタンスをreturnしようとすると

ChaliceResponceでラッピングされてしまうようだったので、utilに切り出しました。

nonceに対応する

今回、IDトークンを利用する実装を行ったので、nonce検証に対応しました。

nonce検証はインプリシットフローでは必須となっています。

そもそもnonce検証の目的は、リプレイアタックへの対策です。

リプレイアタックとは簡単に言うと、URLのフラグメントに設定されたトークンだけ

別のトークンに置き換える攻撃のことです。

インプリシットフローでは必須と書きましたが、その理由としては

URLにトークンが含まれるため、履歴などから簡単に抜き取れるためです。

ただ、フロント実装はいくらでも手元で変更できるので、あまり対策になっていない気もします・・・

あと、認可コードフローでもURLに認可コード含まれない?と一瞬思いましたが

認可コードは1回しか使えないので使用済みのものを抜き取っても使えないですね。

認可コードフロー強し。


src/routes/implicit/callback/+page.svelte

@@ -22,6 +22,13 @@
 			error(401, { message: 'Invalid state parameter.' });
 		}
 
+		// nonceの検証
+		const sessionNonce = sessionStorage.getItem('nonce');
+		const payload = JSON.parse(decodeURIComponent(atob(fragmentArgs.id_token.split('.')[1])));
+		if (sessionNonce != payload.nonce) {
+			error(401, { message: 'Invalid nonce parameter.' });
+		}
+
 		// トークンをセッションストレージに保存
 		sessionStorage.setItem('access_token', fragmentArgs.access_token);
 		sessionStorage.setItem('id_token', fragmentArgs.id_token);


最後に

今回は、アプリ固有の機能として投稿機能を実装し、その中でIDトークンを利用するようになったことから

nonceの検証にも対応しました。

この記事をシェアする