2024-3I プログラミング3 第10回 講義資料

2025年01月09日(木)1・2時限

1 連絡・概要

1.1 今後の流れ

今回の講義の内容は、冬休みの宿題が完了していることを前提したものになります。

ここまでの開発によって、ローカル環境に構築した「ブログアプリ」としては 投稿記事とカテゴリの CRUD (Create / Read / Update / Delete 処理) について、フロントエンドとバックエンドの両方で概ね実装が完了したことになります。

次のステップとしては ユーザ認証機能 を実装していきます。これにより、管理者だけが (=ログインした特定ユーザだけが) 記事やカテゴリの新規作成・編集・削除を実行 できるような仕組みをつくっていきます。

また、次回講義では「画像のアップロード機能」を実装し、次々回の講義では Vercel というホスティングサービスに「ブログアプリ」をデプロイ (配置・展開) し、手持ちのスマートフォンなどから「ブログアプリ」にアクセスできるように公開していきます。

以上のような「認証機能の実装」や「アプリのデプロイ」については、様々な選択肢がありますが、この授業ではSupabaseVercelという2つのウェブサービス (クラウドプラットフォーム) を利用していきます。いずれのサービスも、学習目的 (≠商用利用) であれば無料枠が存在し、一定の制限 (例えば、作成可能なプロジェクト数の制限など) の範囲内であれば クレジットカード登録不要 で使用できます。

2 準備

管理者用の各種画面にアクセスするために、プロジェクトに src/app/admin/page.tsx を作成して、次のコードを貼り付けておいてください。

"use client";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";

const Page: React.FC = () => {
  return (
    <main>
      <div className="mb-2 text-2xl font-bold">管理者用機能の一覧</div>
      <ul>
        <li>
          <FontAwesomeIcon icon={faArrowRight} className="mr-2" />
          <Link className="text-blue-500 underline" href="/admin/posts">
            /admin/posts
          </Link>
        </li>
        <li>
          <FontAwesomeIcon icon={faArrowRight} className="mr-2" />
          <Link className="text-blue-500 underline" href="/admin/posts/new">
            /admin/posts/new
          </Link>
        </li>
        <li>
          <FontAwesomeIcon icon={faArrowRight} className="mr-2" />
          <Link className="text-blue-500 underline" href="/admin/categories">
            /admin/categories
          </Link>
        </li>
        <li>
          <FontAwesomeIcon icon={faArrowRight} className="mr-2" />
          <Link
            className="text-blue-500 underline"
            href="/admin/categories/new"
          >
            /admin/categories/new
          </Link>
        </li>
      </ul>
    </main>
  );
};

export default Page;

開発者モードでアプリを起動し、/admin にアクセスすると次のような画面が表示されることを確認してください。

img

3 プロジェクトのビルド

冬休みの宿題を含めると、これまでに next-blog-app プロジェクト に対して 約30~40 個のファイルを作成・追加してきたことになります。

開発中は、主に npm run dev で「開発モード」でアプリを起動し、エラーやワーニングが生じていないことを確認していると思います。しかし、ここから先の開発をはじめる前に、本番用 (プロダクション用) に「ビルド」を実行し、プロジェクトに問題がないことを改めて確認 してください。

第06回講義のなかで解説したように、以下のコマンドを使って本番環境用(プロダクション環境用)にアプリをビルドすることができます。

npm run build

以上のコマンドを実行して、そのログ (コンソール出力) に エラーやワーニングが報告されないこと を確認してください。もし問題があれば、生成AIなどを利用して解決してください (ワーニングについては、必要に応じて無視も可能です)。

なお、ビルド結果は .next フォルダに出力されます。以下のコマンドを使って本番モード (プロダクションモード) でアプリの実行が可能です。

npm run start

4 認証機能 (ログイン機能) の実装

認証機能 (ログイン機能) を実装していきます。

ここではSupabaseという BaaS (Backend as a Service) を利用して認証機能を実装します。Supabase は、ウェブアプリ/ウェブサービスの開発で一般に必要となる「リレーショナルデータベース」「ファイルストレージ」「認証機能」「サーバレスファンクション」などのバックエンド機能を包括的に提供してくれるサービスになっています。

このブログアプリ開発では、Supabaseの「認証機能」を投稿記事やカテゴリの新規作成・編集・削除の制限に利用する他に、「リレーショナルデータベース機能」を 投稿記事やカテゴリの管理 に利用、また「ファイルストレージ機能」を 記事に添付する画像 (CoverImg) の管理 に利用していきます。

データベースシステムの変更 ( SQLite→PostgreSQL)

ここまでの開発には、データベースとして「SQLite」を使用してきました。しかし、SQLiteは ファイルベースのデータベース であり、本番環境で使用するためには Node.js から直接読み書き可能な永続的なディスク領域を持ったサーバ環境 が必要になります。しかし、そのようなサーバ環境を 無料提供するホスティングサービスは限られているため、以降は、Supabase の無料枠のなかで利用可能な PostgreSQL という RDBMS (Relational DataBase Management System) に移行します。

なお、PostgreSQL は、エンタープライズレベルの本番環境で広く採用されている高機能なオープンソースデータベースシステムです。SQLite が書き込み操作を1つのみに制限するのに対し、PostgreSQLはトランザクション制御により複数ユーザーからの同時アクセスを安全に処理することができます。

4.1 Supabase 登録とプロジェクト作成

Supabase を利用するためには、はじめに「ユーザ登録」が必要になります。Freeプランであれば、クレジットカード不要で利用が可能です。以下の手順で「ユーザ登録」と「組織・プロジェクトの作成と設定」を行なってください。

Supabase の公式ホームページ (https://supabase.com/) にアクセスして「Start your project」のボタンを押下してください。

img

Supabaseは ソーシャルログイン機能 (ソーシャル認証) を提供しており、GitHubアカウントを使ったサインインが可能になっています。この機能を使ってサインインするために「Continue with GitHub」ボタンを押下してください。

img

ソーシャルログインにおける 認可スコープ (=Supabase が利用する GitHubアカウントの情報と権限) の確認画面に遷移するので、内容を確認のうえ「Authorize supbase」ボタンを押下してください。なお、複数のGitHubアカウントを持っている場合は、下記の画面で、どのGitHubアカウントで連携するのかを確認・選択してください。

img

つづいて、以下のような 組織 (Organization) の新規作成を行なう画面「Create a new organization」に移動します。

Supabaseにおける「組織」とは「チームメンバーの管理や料金請求の単位」を指します。無料の「Freeプラン」では、1つの組織を作成して、そこに 最大2つまでのプロジェクト (Project) が作成可能になっています。各プロジェクトでは 1個のデータベース を持つことができます。

Plan の項目が「Free - $0/month」になっていることを確認して「Create organization」のボタンを押下してください。

img

つづいて、以下のようなプロジェクトの作成画面に移動します。

Project Name として Next-Blog-App を設定して、Region (地域) として必ず Northeast Asia (Tokyo) を選択してください。初期設定のままにすると シンガポールのデータセンタ にリソース (データベースなど) が作成され、利用時の応答時間が非常に長くなるので注意してください。

また「Generate a password」のリンクを押下して、データベース用のパスワードを生成してください。なお、このパスワードは「データベース接続文字列」として、環境変数の設定ファイル .env に記述するので、普段使いのパスワードを使用することはお勧めしません

img

パスワードの生成ができたら、以下のように「Copy」のボタンを押下して安全な場所 (ローカルのテキストファイルなど) に貼り付けておいてください。後ほど使用します。

パスワードが保管できたら「Create new project」のボタンを押下してください。

img

プロジェクトの「トップページ」に移動します。「Setting up project」の表示が消えるまで (データベースを含む Supabase プロジェクトが作成され、初期設定が完了するまで) しばらく待機してください。

img

「Setting up project」の表示が消えたら、Supabase の データベース機能 (PostgreSQL) が実際に利用可能になります🎉。

4.2 Prismaのデータベース接続情報の更新

現在、プロジェクトにおいて ORM (O/R Mapper) である Prisma は、プロジェクトローカルに配置した prisma/dev.db (SQLiteデータベース) と接続するように設定されています。これを Supabase (クラウドプラットフォーム) に構築された PostgreSQL に切り替えるように設定 (接続情報) を変更していきます。

まずは Supabase のウェブページから、Supabase に構築された「PostgreSQLに接続 (アクセス) するための情報」を取得します。次のように画面上部の「 Connect」のボタンを押下してください。

img

ダイアログが表示されるので「ORMs」を選択肢します。

img

次のように Tool が「Prisma」になっていることを確認して、「.env.local」タブを選択し、「Copy」ボタンを押下して 環境変数の設定 をコピーしてください。

img

VSCodeを起動し、プロジェクトフォルダ直下に配置されている .env (環境変数の設定ファイル) を開いてください。

コメント部分を除けば、.env は以下のような内容になっているはずです。今後、microCMS API は使用しないので、それに関連する設定は「削除」または「コメントアウト」しておいてください。

NEXT_PUBLIC_MICROCMS_API_KEY=xxxxxxxxxxxxxx
NEXT_PUBLIC_MICROCMS_BASE_EP=https://xxxxxxxx.microcms.io/api/v1
DATABASE_URL="file:./dev.db"

これを以下のように変更してください。先ほど Supabase のサイトで「コピー」したテキストを貼り付け、それを加筆修正してください。この設定を間違うと、DB接続に失敗するので慎重に細心の注意を払って設定してください。

# Connect to Supabase via connection pooling with Supavisor.
DATABASE_URL="postgresql://postgres.XXX:YYY@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"

# Direct connection to the database. Used for migrations.
DIRECT_URL="postgresql://postgres.XXX:YYY@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"

.gitignore の内容を再確認

既に第06回講義 において設定しているハズですが、再度 .gitignore のなかに .env が含まれていること (.env が GitHub にアップロードされないようになっていること) を再確認してください。

つづいて、Supabase サイトのほうで prisma/schema.prisma にタブ表示を切り替えてください。 そして、次のようにテキストをコピーしてください。

img

VSCode に戻って prisma/schema.prisma の当該箇所を次のように更新してください。model Post {... 以降は変更不要です。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

以上で設定ファイルの更新は完了です。

注意

Freeプランで作成した Supabase のプロジェクトは、7日間、非アクティブ状態が継続すると (=データベースや認証機能などに対するアクセスがないと)「paused」という状態になってしまいます。この状態では、Supabase の API を叩いても機能しないので注意 してください。

プロジェクトが「paused」になってしまった場合は、ブラウザから Supabase にアクセスして解除することができます (解除には数分かかることがあります)。アプリ開発中は7日間放置することはないと思いますが、完成後は、この状態になることがあるので十分に注意してください。特に、ポートフォリオとして開発したウェブアプリのURLを提出する際には、注意してください。

paused」に切り替わったときは、以下のようなメールが届きます (メールのなかのリンクからアクセスしたページで「アクティブ状態」に戻すことができます)。

Hi there,

To save on cloud resources we’re currently pausing free-tier projects that are inactive for more than 7 days.

Your project XXXXXX has been paused.

You can unpause it from the dashboard within the next 90 days. Beyond that point, you won’t be able to unpause your project, but you’ll be able to download your data. For more information read the docs.

If you want, you can upgrade your organization to Pro to avoid future pausings, and this can be done from the billing settings.

▼▼ paused になってしまったプロジェクト

img

▼▼ 解除のための操作

paused なプロジェクトを選択すると、以下のダイアログが表示されるので「Restore project」を選択します。

img

4.3 データベース接続とテーブル作成 (初期化)

更新された設定に基づき、Supabase に構築された PostgreSQL に 各種テーブルを作成 していきます。

基本的に SQLite の場合と同様に Primsa 経由でテーブルの作成と初期化 をします。VSCodeのターミナルから次のコマンドを実行してください。

npx prisma db push

次のような応答 (Your database is now in sync with your Prisma schema) が返ってくれば、Supabaseに構築したDBの初期化処理 (つまり PostCategoryPostCategory3つのテーブルの作成) が成功したことになります。

エラーとなったときは .envprisma/schema.prisma の内容を再確認してください (ファイルの 保存忘れ などに注意してください)。

Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": PostgreSQL database "postgres", schema "public" at "aws-0-ap-northeast-1.pooler.supabase.com:5432"

Your database is now in sync with your Prisma schema. Done in 1.09s

✔ Generated Prisma Client (v6.0.1) to .\node_modules\@prisma\client in 12

各テーブルが作成されたことを Supbase で確認してみます。以下の手順で「Table Editor」にアクセスしてテーブルが作成されていることを確認してください。

img

ブログアプリからも、 Supabase のテーブルにレコードが追加できること を確認してみます。アプリを開発モードで起動して (npm run dev)、実際に記事を投稿してみてください。

img

Supbase から Post テーブルを確認してください。次のようにレコードが追加されていれば成功🎉です (反映されていないときは画面をリロードしてください)。

img

以上が確認できれば、SQLite から PostgreSQL への切り替えは無事完了です。

Supabaseの「Schema Visualizer」機能について

Supabase の「Schema Visualizer」という機能を使用して、データベースのスキーマ (=構造やテーブル間の関係) を視覚的に確認することができます。実際に確認してみてください (テーブルの位置はマウスドラッグで変えることができます)。ユーザーが作成したテーブルは、すべて public というスキーマ内で管理されます。

img

次に、上記の ❸ のドロップダウンリストを auth に切り替えてみてください。

先に説明しているように Supabase は「ユーザ認証機能」も提供しますが、その認証に使っているテーブル構造が確認できます。認証機能は複数のテーブルで構成されており、ユーザーの基本情報を管理する users テーブルや、セッション情報を扱う sessions テーブルのほか、多要素認証やパスワードリセットなどの高度なセキュリティ機能をサポートするための補助テーブルが使われています。

img

4.4 テーブルの削除

テーブルに新しくカラムを追加した場合など、prisma/schema.prisma を変更したときは、再度 npx prisma db push を実行する必要があります (本番運用の開始後は npx prisma migrate という「既存レコードを残したままスキーマを変更するコマンド」を使用してください)。

npx prisma db push を再実行する際には、事前に既存テーブルを削除 しておく必要があります。SQLite を使用していたときは dev.db を削除することでテーブルを削除することができました。一方、Supabase の PostgreSQL では、次のような SQL文を実行してテーブルを削除する必要があります (GUIを使ってテーブルを削除することも可能です)。

DROP TABLE IF EXISTS
  public."Post",
  public."Category",
  public."PostCategory"
CASCADE;

大文字を含むテーブルを指定するときは、ダブルクォーテーションで囲む必要がある ので注意してください。また、public. というスキーマ指定も必要になります。具体的には、次のような手順で、Supabase の「SQL Editor」から SQL文 を実行してください (実際に試してみてください)。

img

Run」ボタンを押下すると (Ctrl+Enter を入力すると)、次のような確認ダイアログが表示されます。これは、テーブルの削除などの破壊的な操作を含むSQLを実行するときに表示されるダイアログになります。内容を確認して「Run this query」を押下してください。

img

「Table Editor」の画面に移動し、次のように テーブルが削除できていること を確認してください。

img

4.5 テーブル作成とRLS設定

削除したテーブルを再作成します (npx prisma generate も忘れずに実行してください)。また、さらに、第08回講義で実行したようにprisma/seed.ts を使って 初期データを投入 します。

VSCodeに戻って、ターミナルから以下のコマンドを実行してください。

npx prisma db push
npx prisma generate
npx prisma db seed

Supabase の「Table Editor」で、次のように初期データの投入を含めて問題なく実行できていることを確認してください。

img

また、この画面において以下の図のように、テーブルの右の鍵マークが「」、テーブルのステータスが「RLS disabled」になっていることを確認してください。ここで RLS とは Row Level Security の頭文字をとったもので テーブルの行単位 (レコード単位) での読み書きを制限する仕組み・機能 になります。

この RLS を無効 (disabled) のまま放置すると、ANON_KEY を設定した SupabaseClient (@supabase/supabase-jsで導入してフロントエンドで動作するもの) を通じて テーブルレコードの読み取り、編集、削除などが制限なく可能 になってしまうという問題があります。ANON_KEY は、フロントエンドプログラムから読み取り可能であるため、少しの知識があれば、それを読み取って悪用可能であり、非常に大きなセキュリティリスク (脆弱性) となってしまいます。

img

この問題を解決するために「SQL Editor」から、次のような SQL を実行して 全てのテーブルで RLS を有効化 してください (GUIからも実行可能です)。成功すると「Success. No rows returned」のログが出力されます。

ALTER TABLE public."Post" ENABLE ROW LEVEL SECURITY;
ALTER TABLE public."Category" ENABLE ROW LEVEL SECURITY;
ALTER TABLE public."PostCategory" ENABLE ROW LEVEL SECURITY;
img

再び Table Editor に戻って「RLS が有効になっていること」を確認してください (反映されていないときは、ページをリロードしてください)。

img

4.6 ログイン可能なユーザの追加

Supabase の認証機能を使って メールアドレス (ログインID)パスワード を使って認証可能なユーザを作成していきます。この処理はウェブアプリ上に「サインアップ (ユーザ登録)」のページを実装して、そこから行なうこともできます。しかし、このブログアプリでは 管理者1名だけの認証ができれば問題ない ので、Supabase のサイトから手動でユーザを登録していきます。

次のように「Add user」のボタンを押下し、「Create new user」を選択してください。

img

次のようなダイアログが表示されるので、メールアドレスとパスワードを設定して、「Create user」のボタンを押下してください。

なお「Auto Confirm User?」にチェックを入れていると、「認証メールの送信プロセス」が省略されるので、メールアドレスは架空のもの (admin@example.com など) でも問題ありません。

img

次のように、ユーザが追加されたことを確認してください。

img

4.7 フロントエンドで使用するAPIキーの取得

ブログアプリのフロントエンドプログラムにおいて、SupabaseClient (@supabase/supabase-js) を使って Supabase の「認証機能」や「PostgreSQL」にアクセスするための APIキー を取得します。なお、バックエンドプログラムでは 基本的に Prisma 経由 で PostgreSQL にアクセスします。

以下の2つのキーを取得してください。

img

注意

ここで取得する APIキー NEXT_PUBLIC_SUPABASE_ANON_KEY は、ウェブブラウザで動作する「フロントエンドプログラム」から参照する値であり、その性質上、利用者に読み取られる可能性があります。そのため、以下のような注意が書かれています。

This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies.

(訳) テーブルの Row Level Security (RLS) を有効化し、適切なポリシーを設定している場合、このキーはフロントエンド環境(ウェブブラウザ)でも安全に使用可能です。

VSCode に戻って次のように環境変数の設定ファイル .env を編集してください。

# Connect to Supabase via connection pooling with Supavisor.
DATABASE_URL="postgresql://postgres.XXX:YYY@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"

# Direct connection to the database. Used for migrations.
DIRECT_URL="postgresql://postgres.XXX:YYY@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"
# Connect to Supabase via connection pooling with Supavisor.
DATABASE_URL="postgresql://postgres.XXX:YYY@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"

# Direct connection to the database. Used for migrations.
DIRECT_URL="postgresql://postgres.XXX:YYY@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"

NEXT_PUBLIC_SUPABASE_URL=https://XXX.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YYYYYYYYYYYYYY

なお、環境変数のうち NEXT_PUBLIC_ から始まる値は フロントエンドで参照可能な値 になります。利用者に読み取られても問題のない値だけ、NEXT_PUBLIC_ を付けるようにしてください。バックエンドでは全ての値を参照可能です。

4.8 SupabaseClient のインストール

VSCode のターミナルから次のコマンドを実行して、SupabaseClient を使用するために必要なライブラリをインストールしてください。

npm i @supabase/supabase-js

また、プロジェクトに src/utils/supabase.ts を新規作成して、以下の内容を記述してください。これは、環境変数を読み取って SupabaseClient (=データベース操作用のAPI操作ツール) のインスタンスを生成、初期化するプログラムになります。

import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

以降、import { supabase } from "@/utils/supabase" のようにインポートすれば、SupabaseClient である supabase を通じて Supabase の各種機能 (認証機能やデータベース) を利用すること ができます。

4.9 ログインページの実装

ブログアプリに、IDとパスワードを使った「ログインページ」を実装していきます。

プロジェクトに src/app/login/page.tsx を新規作成してこちらのプログラムを貼り付けて、保存してください。

さらに、src/app/_components/ValidationAlert.tsx を新規作成して、以下のプログラムを貼り付けて、保存してください。これは login/page.tsx から参照しているコンポーネントで、バリデーションエラーを表示するためのものになります。

"use client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons";

const ValidationAlert = ({ msg }: { msg: string }) => {
  if (msg === "") {
    return null;
  }
  return (
    <div className="flex items-center space-x-1 text-sm font-bold text-rose-400">
      <FontAwesomeIcon icon={faCircleExclamation} className="mr-0.5" />
      <div>{msg}</div>
    </div>
  );
};

export default ValidationAlert;

開発モードでアプリを起動して /login にアクセスすると、次のような画面が表示されることを確認してください。

img

この画面で、誤ったメールアドレスとパスワードを入力して「ログイン」を押下すると、次のようになることを確認してください。また、その際、ブラウザのデベロッパーツールのコンソールタブの出力も確認 してください。SupabaseClient が (バックグラウンドで) SupabaseAPI にリクエストを送って 400 Bad Request がレスポンスされていることが確認できます。また、認証に失敗時には SupabaseClient から返されるエラーオブジェクトの内容を確認することができます。

img

次に Supabase のユーザ管理画面で設定したメールアドレスとパスワードを入力して「ログイン」のボタンを押下すると /admin に「リダイレクト (=ページ移動)」 することを確認してください。

4.10 ログイン処理の解説

src/app/login/page.tsx の内容について解説していきます。やや長めのプログラムですが、ログイン処理に関する本質的な部分は、次の箇所になります。

まず、第07行目src/app/utils/supabase.ts から SupabaseClient をインポートしています。

import { supabase } from "@/utils/supabase";

「ログイン」のボタンが押下されると、第29行目handleSubmit 関数がコールされ、UI/UX関連の処理後に、次のコードが実行されます。

try {
  console.log("ログイン処理を実行します。");
  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });
  if (error) {
    setLoginError(
      `ログインIDまたはパスワードが違います(${error.code})。`
    );
    console.error(JSON.stringify(error, null, 2));
    return;
  }
  console.log("ログイン処理に成功しました。");
  router.replace("/admin");
} catch (error) {
  setLoginError("ログイン処理中に予期せぬエラーが発生しました。");
  console.error(JSON.stringify(error, null, 2));
} finally {
  setIsSubmitting(false);
}

メールアドレスとパスワードを使ったログイン (サインイン) を試みているのが 第36行目supabase.auth.signInWithPassword メソッドになります。ログインに成功すると error には null が格納され、失敗すると以下のような エラーオブジェクト が格納されます (以下には error オブジェクトをJSONに変換したものを掲載しています)。

{
  "__isAuthError": true,
  "name": "AuthApiError",
  "status": 400,
  "code": "invalid_credentials"
}

error が null ではないとき (=つまり認証に失敗したとき) は 第40行目 の if 文が実行され、第45行目return により handleSubmit 関数を抜けています。一方で、認証に成功したときは、第48行目 の処理によって /admin のページに リダイレクト (=ページ移動) するようになっています。

この router.replace(...) は、以下のように 第08行目 でインポートして、第17行目 で取得している useRouterフック のメソッドになります。useRouter は ページ遷移を操作するための機能セット を提供するものになります。

import { useRouter } from "next/navigation";
const router = useRouter();

supabase.auth.signInWithPassword() によるログインに成功すると、Supabaseからは アクセストークン (Access Token) と呼ばれる JWT (JsonWebToken) 形式の文字列 が発行され、その他の情報と共に、 ウェブブラウザのローカルストレージsb-XXXX-auth-token というキーで 認証情報 が保存されます (XXXX の部分には Supabase の ProjectID が入ります)。このアクセストークン (以下、トークン) とは、認証の結果、つまり、認証済みであることの証明書 としての役割を持ちます。次回講義では「バックエンドにおけるユーザ認証の手順」を解説しますが、そこでトークンを利用することになります。

ローカルストレージに保存された情報 (トークンを含む) は、デベロッパーツール (F12で起動) を使って、以下のように確認できます。access_tokenトークン (JWT) になります。なお、後述の「ログアウト処理」を実行すると、この認証情報はローカルストレージから削除されます。

img

一見すると、トークン (JWT) はランダムな文字列のように見えますが、実体としては JSON を BASE64URLエンコードした文字列 と、その文字列に対して秘密鍵(JWT Secret)を用いて生成された 署名 (ハッシュ) を結合した文字列として構成されています。

4.11 演習

トークン (JWT) は、JSON文字列をBASE64URLエンコードしてつくられているので、デコードすれば元のJSON が復元可能です。JWTの公式ページでは、そのデコード処理がブラウザ上で実行できるので、以下の手順で試してください。

  1. デベロッパーツールから access_token を取得して (項目を選んで右クリックから「値をコピー」して)、その内容を以下のサイトに貼り付けてください。ログインしていない状態では access_token が存在しないので注意してください。

  2. JWTの公式ページ (https://jwt.io/) にアクセスして、Encoded に値を貼り付けてください。

  3. Decoded の PAYLOAD から、復元された JSON の内容を確認してください。

img

JWTの「構成」や「署名の検証方法」などの仕組み (なぜ、これで安全な認証ができるのか、改竄が不可能なのか) について興味がある学生は、以下のウェブ記事や、生成AIなどを利用して学んでください。

5 認証状態の取得・確認

前のセクションでは、SupabaseClient の auth.ignInWithPassword() メソッドに ID と パスワード を与え、ユーザの認証 (Authentication) ができることを確認しました。また、このメソッドは、認証に成功するとブラウザのローカルストレージに認証情報を格納し、失敗するとエラー情報を格納したオブジェクトを戻り値として返すことを確認しました。

しかし、現状では、ブラウザのアドレスバーに /admin を打ち込めば 認証 (Authentication) の有無に関わらず「管理者用ページ」が表示できてしまいます。ここでは、認証されたユーザだけが「管理者用ページ」を表示できるようにするための 認可 (Authorization) という処理について学んでいきます。

認証 (Authentication) と認可 (Authorization)

言葉として非常に似ていますが、ウェブアプリ開発の文脈では、次のように区別されるので注意してください。

ここでは、認証済みユーザだけに /admin 配下のURLパス (/admin/*) の表示と、それに関連するウェブAPI (/api/admin/*) の利用を許可するようなシンプルな認可処理を実装していきます。

5.1 フロントエンドの認可、カスタムフックの定義

フロントエンドでは、SupabaseClient の auth.getSession() メソッドで「現在のユーザーの認証状態」が確認できます。認証済みの場合は セッションオブジェクト が、未認証の場合は null が返されます。なお、セッションオブジェクトからは session.user.email でユーザのメールアドレス (ログインID) が取得可能となっています。

しかし、src/app/admin/ 配下にある全ての page.tsxauth.getSession() 関連のロジックを個別に実装することは コードの重複を招き、保守性を著しく低下 させることになります。そのため、ここでは useAuth という「カスタムフック」の作成と、layout.tsx を用いた「ルートガード (詳細は後述)」の実装により、認可処理を一元管理するようにしていきます。

まずは、認可関連の処理を「カスタムフック」と呼ばれる形式で実装します。認可処理を適切に行なうには useStateuseEffect などの ReactHooks が必要になるのですが、これらは 通常の関数 (普通に定義した関数) の内部では利用できない という制約があります。ReactHooks を使用するときは「カスタムフック」という形式で、一定の規則・制約に従って処理を記述すること が求められます (例えば「ファイル名」と「関数名」を useXXX.ts の形式で設定するなどの規則に従う必要があります) 。

(プロンプト例)

Next.js + TypeScript でウェブアプリを開発しています。「カスタムフック」と「普通の関数」の違いについて教えてください。保守性と再利用性を考えて処理を分離したいと考えているのですが、どちらで実装するべきですか。

認可処理に使用する useAuth というカスタムフックを作成します。src/app/_hooks/useAuth.ts というファイルを作成して、以下のプログラムを貼り付けてください。

import { useState, useEffect } from "react";
import { Session } from "@supabase/supabase-js";
import { supabase } from "@/utils/supabase";

export const useAuth = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [session, setSession] = useState<Session | null>(null);
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    // 初期セッションの取得
    const initAuth = async () => {
      try {
        const {
          data: { session },
        } = await supabase.auth.getSession();
        setSession(session);
        setToken(session?.access_token || null);
        setIsLoading(false);
      } catch (error) {
        console.error(
          `セッションの取得に失敗しました。\n${JSON.stringify(error, null, 2)}`
        );
        setIsLoading(false);
      }
    };
    initAuth();

    // 認証状態の変更を監視
    const { data: authListener } = supabase.auth.onAuthStateChange(
      async (event, session) => {
        setSession(session);
        setToken(session?.access_token || null);
      }
    );

    // アンマウント時に監視を解除(クリーンアップ)
    return () => authListener?.subscription?.unsubscribe();
  }, []);

  return { isLoading, session, token };
};

このカスタムフックは page.tsxlayout.tsx、その他のカスタムフックのなかで、次のように使用します。

import { useAuth } from "@/app/_hooks/useAuth";
const { isLoading, session, token } = useAuth();

まずは、useAuth の「使用方法」から先に確認していきます。このフックは、次の3つの値を返します。

想定する使い方としては、認可が必要なページにおいて、isLoadingtrue のときは「Loading…」を表示し、これが false に切り替わったら session を確認して、もし null なら トップページにリダイレクトし、そうでなければ コンテンツを出力 します。また、認可が必要なウェブAPIにリクエストする際、HTTP Header に token を追加します。


次に useAuth の内部処理 src/app/_hooks/useAuth.ts を解説します。

まず、第06行目 から 第08行目 にかけて、useState を利用して 3つの状態 (isLoadingsessiontoken) を定義します。初期状態では isLoadingtrue、他は null になります。

これら3つの値を useState を使って「状態」として管理することで、これらの値が更新されたときに、それを参照している「Reactコンポーネント」に 再レンダリングがかかる というメリットがあります。通常の変数として定義すると、この便利な「更新検知の仕組み」が機能しないことに注意してください。

つづいて、第10行目 からの useEffect は、大きく2つ役割を持ちます。まず、第1の役割として 認証状態の取得 を担います。useAuth() がコールされたタイミングで auth.getSession() を実行し、その結果に応じて、3つの状態 (isLoadingsessiontoken) を更新します。

第2の役割として、認証状態の変更監視 を設定しています。これにより、ログアウトなどによって認証状態が変化した場合、onAuthStateChange イベントが発生して sessiontoken の状態が更新される仕組みをつくっています。また、コンポーネントのアンマウントされるときには、監視を解除して、メモリリークを防止しています。

(プロンプト例)

Next.js でウェブアプリを開発しています。コンポーネントの「マウント」と「アンマウント」とは何ですか。useEffect との関係を含めて解説してください。

第41行目では、3つの状態を単一のオブジェクトとして返却し、この useAuth() を使用するコンポーネントから参照できるようにしています。

5.2 useAuth を利用したヘッダの表示パターンの切り替え

認証状態に応じて、次のようにヘッダの表示パターンの切り替え処理 (未認証のときは「Login」、認証済みのときは「Logout」と表示する処理) を実装してきます。

img
img

このような処理を 出し分け や「条件分岐表示」「動的切り替え」のように表現します。

実装例を以下に示します。Headerコンポーネントは 既に各自でカスタマイズしているので思うので、必要な部分だけを書き換えるようにしてください。

"use client";
import Link from "next/link";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFish } from "@fortawesome/free-solid-svg-icons";
import { supabase } from "@/utils/supabase"; // ◀ 追加
import { useAuth } from "@/app/_hooks/useAuth"; // ◀ 追加
import { useRouter } from "next/navigation"; // ◀ 追加

const Header: React.FC = () => {
  // ▼ 追加
  const router = useRouter();
  const { isLoading, session } = useAuth();
  const logout = async () => {
    await supabase.auth.signOut();
    router.replace("/");
  };
  // ▲ 追加

  return (
    <header>
      <div className="bg-slate-800 py-2">
        <div
          className={twMerge(
            "mx-4 max-w-2xl md:mx-auto",
            "flex items-center justify-between",
            "text-lg font-bold text-white"
          )}
        >
          <div>
            <Link href="/">
              <FontAwesomeIcon icon={faFish} className="mr-1" />
              MyBlogApp
            </Link>
          </div>
          <div className="flex gap-x-6">
            {/* ▼ 追加 */}
            {!isLoading &&
              (session ? (
                <button onClick={logout}>Logout</button>
              ) : (
                <Link href="/login">Login</Link>
              ))}
            {/* ▲ 追加 */}
            <Link href="/about">About</Link>
          </div>
        </div>
      </div>
    </header>
  );
};

export default Header;

第07行目useAuth をインポートして、第13行目useAuth() を実行し、その戻り値を分割代入しています (このコンポーネントでは token を使用しないので、isLoadingsession だけを得ています)。

第38行目 から 第41行目 で、出し分け (条件分岐表示) をしています。未認証のときは /login ページにリンクする「Login」を表示し、認証済みの場合は 第14行目 で定義している「ログアウト処理」をコールする「Logout」を表示するようにしています。また、認証状態の確認中は、いずれも表示しない用にしています。

第14行目 では、ログアウト処理を定義しています。また、ログアウト後はトップページにリダイレクトするようにしています。

5.3 演習

開発者モードでアプリを起動し、ヘッダ部が期待するような動作をすることを確認してください。

6 ルートガードの実装

ルートガード (Route Guard) とは 認証状態に基づいて特定のルート (URLパス) に対するアクセスを制御し、権限を持たないユーザを適切なページへリダイレクトする機能 を指します。

このブログアプリでは、/admin/* へのアクセスがあったとき、ユーザ認証済みであればコンテンツを表示し、そうでなければ ログインページにリダイレクト するようなルートガードを構築します。

ルートガードは、保守性を考慮して個々の page.tsx ではなく src/app/admin/layout.tsx に設置します。第06回講義で解説したように layout.tsx は、それが配置される階層以下の全てのページに適用されるので src/app/admin に配置すれば、/admin/admin/posts/new など /admin 以下の全てのアクセスにルートガードを適用することができるようになります。

📂 src/app/
├─ 📄 page.tsx
├─ 📄 layout.tsx
└─ 📂 admin/
    ├─ 📄 page.tsx
    ├─ 📄 layout.tsx  # ここにルートガードを設置
    ├─ 📂 categories/
    └─ 📂 posts/
        ├─ 📄 page.tsx
        ├─ 📂 [id]/
        └─ 📂 new/
            └─ 📄 page.tsx
img

src/app/admin/layout.tsx を作成して、以下のプログラムを貼り付けてください。

"use client";
"use client";

import React from "react";
import { useAuth } from "@/app/_hooks/useAuth";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

interface Props {
  children: React.ReactNode;
}
const AdminLayout = ({ children }: Props) => {
  const router = useRouter();
  const { isLoading, session } = useAuth();

  useEffect(() => {
    // 認証状況の確認中は何もせずに戻る
    if (isLoading) {
      return;
    }
    // 認証確認後、未認証であればログインページにリダイレクト
    if (session === null) {
      router.replace("/login");
    }
  }, [isLoading, router, session]);

  // 認証済みが確認できるまでは何も表示しない
  if (!session) {
    return null;
  }
  return <>{children}</>;
};

export default AdminLayout;

開発者モードで起動して、未ログイン状態で /admin/admin/posts/admin/posts/new にアクセスすると (ウェブブラウザのアドレバーにパスを入力すると)、ログインページ (/login) に移動すること、つまり、ルートガードが機能することを確認してください。

6.1 演習

再利用性と保守性を高めるために、ルートガードの処理を src/app/_hooks/useRouteGuard.ts というカスタムフック として分離し、それを src/app/admin/layout.tsx から呼び出すようにしてください。

7 課題2

Google Classroom で配付しているワードファイル NN-課題2-氏名.docx を雛形に、「リポジトリのURL」の他、以下の各ページの「スクリーンショット」と「画面や機能の解説・アピールポイント」を記載して提出してください。

URL 処理
/admin/categories カテゴリの一覧表示、編集ページへのリンク、削除機能 (オプション)
/admin/posts 投稿記事の一覧表示、編集ページへのリンク、削除機能 (オプション)
/admin/posts/[id] 投稿記事の編集 (削除を含む)

7.1 スマホ版のスクショの撮影方法

課題に添付するスマートフォン表示のスクリーンショット (iPhone 16 Plus を想定) は、必ず以下の手順で取得してください。

img
img

名前を「iPhone 16 Plus」、画面サイズを「430x932」に設定したエミュレートデバイスを追加してください。

img

デバイスから「iPhone 16 Plus」を選択して、「Capture full size screenshot (フルサイズのスクリーンショットをキャプチャ)」を使って画面全体のスクリーンショットを取得してください。

img