認証
一般的に、認証は以下のステップで構成されます。
- ユーザーから資格情報を取得する
- それをバックエンドに送信する
- 認証されたリクエストを送信するためのトークンを保存する
ユーザーの資格情報を取得する方法
OAuthを通じて認証を行う場合は、OAuthプロバイダーのページへのリンクを持つログインページを作成し、ステップ3に進むことができます。
ログイン用の別ページ
通常、ウェブサイトにはユーザー名とパスワードを入力するためのログイン専用ページがあります。これらのページは非常にシンプルであるため、分解する必要はありません。さらに、ログインフォームと登録フォームは外見が非常に似ているため、同じページにグループ化することもできます。ログイン/登録ページ用のスライスをPages層に作成します。
- 📂 pages
- 📂 login
- 📂 ui
- 📄 LoginPage.tsx
- 📄 RegisterPage.tsx
- 📄 index.ts
- 📂 ui
- その他のページ…
- 📂 login
ここでは、2つのコンポーネントを作成し、インデックスで両方をエクスポートしました。これらのコンポーネントは、ユーザーが資格情報を入力するためのわかりやすい要素を含むフォームを持ちます。
ログイン用のダイアログボックス
アプリケーションにどのページでも使用できるログイン用のダイアログボックスがある場合は、そのダイアログボックス用のウィジェットを作成できます。これにより、フォーム自体をあまり分解せずに、どのページでもこのダイアログボックスを再利用できます。
- 📂 widgets
- 📂 login-dialog
- 📂 ui
- 📄 LoginDialog.tsx
- 📄 index.ts
- 📂 ui
- その他のウィジェット…
- 📂 login-dialog
このガイドの残りの部分は、ログインが別ページで行われる最初のアプローチに基づいていますが、同じ原則がダイアログボックス用のウィジェットにも適用されます。
クライアントバリデーション
たまには、特に登録時に、クライアント側で検証を行い、ユーザーにエラーを迅速に通知することがあります。この場合、検証は、ログインページのmodel
セグメントで行うことができます。スキーマ検証ライブラリ、例えばZodをJS/TS用に使用し、このスキーマをui
セグメントに提供します。
import { z } from "zod";
export const registrationData = z.object({
email: z.string().email(),
password: z.string().min(6),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "パスワードが一致しません",
path: ["confirmPassword"],
});
次に、ui
セグメントでこのスキーマを使用してユーザー入力を検証できます。
import { registrationData } from "../model/registration-schema";
function validate(formData: FormData) {
const data = Object.fromEntries(formData.entries());
try {
registrationData.parse(data);
} catch (error) {
// TODO: ユーザーにエラーメッセージを表示
}
}
export function RegisterPage() {
return (
<form onSubmit={(e) => validate(new FormData(e.target))}>
<label htmlFor="email">メールアドレス</label>
<input id="email" name="email" required />
<label htmlFor="password">パスワード(最小6文字)</label>
<input id="password" name="password" type="password" required />
<label htmlFor="confirmPassword">パスワードを確認</label>
<input id="confirmPassword" name="confirmPassword" type="password" required />
</form>
)
}
資格情報をバックエンドに送信する方法
バックエンドのログインエンドポイントにリクエストを送信する関数を作成しましょう。この関数は、コンポーネントのコード内でミューテーションライブラリ(例えば、TanStack Query)を通じて直接呼び出すことも、状態管理ライブラリの副作用として呼び出すこともできます。
リクエスト関数をどこに置くか
この関数を置く場所は2つあります: shared/api
またはページのapi
セグメントです。
shared/api
に
このアプローチは、すべてのリクエスト関数をshared/api
に配置し、エンドポイントごとにグループ化するのに適しています。この場合、ファイル構造は次のようになります。
- 📂 shared
- 📂 api
- 📂 endpoints
- 📄 login.ts
- その他のリクエスト関数…
- 📄 client.ts
- 📄 index.ts
- 📂 endpoints
- 📂 api
📄 client.ts
ファイルには、リクエストを実行するためのプリミティブのラッパーが含まれています(例えば、fetch()
)。このラッパーは、バックエンドのベースURLを知っており、必要なヘッダーを設定し、データをシリアライズします。
import { POST } from "../client";
export function login({ email, password }: { email: string, password: string }) {
return POST("/login", { email, password });
}
export { login } from "./endpoints/login";
ページのapi
セグメントに
すべてのリクエストを1か所に保存していない場合は、ログインページのapi
セグメントにこのリクエスト関数を配置するのが適しているかもしれません。
- 📂 pages
- 📂 login
- 📂 api
- 📄 login.ts
- 📂 ui
- 📄 LoginPage.tsx
- 📄 index.ts
- 📂 api
- その他のページ…
- 📂 login
import { POST } from "shared/api";
export function login({ email, password }: { email: string, password: string }) {
return POST("/login", { email, password });
}
この関数は、ページのインデックスから再エクスポートする必要はありません。なぜなら、恐らくこのページ内でのみ使用されるからです。
2要素認証
アプリケーションが2要素認証(2FA)をサポートしている場合、ユーザーを一時的なパスワードを入力するための別のページにリダイレクトする必要があるかもしれません。通常、POST /login
リクエストは、ユーザーに2FAが有効であることを示すフラグを持つユーザーオブジェクトを返します。このフラグが設定されている場合、ユーザーを2FAページにリダイレクトします。
このページはログインと非常に関連しているため、Pages層の同じlogin
スライスに配置することもできます。
また、上で作成したlogin()
に似た別のリクエスト関数が必要になります。それらをShared層にまとめるか、ログインページのapi
セグメントに配置してください。
認証されたリクエスト用のトークンを保存する方法
使用する認証スキームに関係なく、単純なログインとパスワード、OAuth、または2要素認証であっても、最終的にはトークンを取得します。以降のリクエストで自分を識別できるように、このトークンは保存する必要があります。
ウェブアプリケーションにおけるトークンの理想的な保存場所はクッキーです。クッキーはトークンの手動保存や処理を必要としません。したがって、クッキーの保存はフロントエンドアーキテクチャにほとんど労力を必要としません。フロントエンドフレームワークにサーバーサイドがある場合(例えば、Remix)、クッキーのサーバーインフラはshared/api
に保存する必要があります。「認証」チュートリアルセクションには、Remixでの実装例があります。
ただし、時にはトークンをクッキーに保存することができない場合もあります。この場合、トークンを自分で保存しなければなりません。その際、トークンの有効期限が切れたときに更新するロジックを書く手間がかかるかもしれません。FSDの枠組み内には、トークンを保存できるいくつかの場所と、そのトークンをアプリケーションの他の部分で利用できるようにするいくつかの方法があります。
Shared層に保存する
このアプローチは、APIクライアントがshared/api
に定義されている場合にうまく機能します。なぜなら、APIクライアントがトークンに自由にアクセスできるからです。クライアントが状態を持つようにするには、リアクティブストアを使用するか、単にモジュールレベルの変数を使用することができます。その後、login()
/logout()
関数内でこの状態を更新できます。
トークンの自動更新は、APIクライアント内のミドルウェアとして実装できます。これは、リクエストを行うたびに実行されます。例えば、次のようにすることができます。
- 認証し、アクセストークンとリフレッシュトークンを保存する
- 認証を必要とするリクエストを行う
- リクエストがアクセストークンの有効期限切れを示すステータスコードで失敗した場合、ストレージにリフレッシュトークンがあれば、更新リクエストを行い、新しいアクセストークンとリフレッシュトークンを保存し、元のリクエストを再試行する
このアプローチの欠点の1つは、トークンの保存と更新ロジックが専用の場所を持たないことです。これは、特定のアプリケーションやチームには適しているかもしれませんが、トークン管理のロジックがより複雑な場合、リクエスト送信とトークン管理の責任を分けたいと思うかもしれません。この場合、リクエストとAPIクライアントをshared/api
に置き、トークンストレージと更新ロジックをshared/auth
に配置します。
このアプローチのもう1つの欠点は、サーバーがトークンとともに現在のユーザーに関する情報を返す場合、その情報を保存する場所がなく、特別なエンドポイント(例えば/me
や/users/current
)から再度取得する必要があることです。
Entities層に保存する
FSDプロジェクトには、ユーザーエンティティや現在のユーザーエンティティが存在することがよくあります。これらは同じエンティティである場合もあります。
現在のユーザーは時には「viewer」や「me」とも呼ばれます。これは、権限とプライベート情報を持つ認証されたユーザーと、公開情報を持つ他のすべてのユーザーを区別するために行われます。
ユーザーエンティティにトークンを保存するには、model
セグメントにリアクティブストアを作成します。このストアには、トークンとユーザー情報のオブジェクトの両方を含めることができます。
APIクライアントは通常、shared/api
に配置されるか、エンティティ間で分散されるため、このアプローチの主な問題は、他のリクエストがトークンにアクセスできるようにしつつ、レイヤーのインポートルールを破らないことです。
スライス内のモジュールは、下層にあるスライスのみをインポートできる。
この問題にはいくつかの解決策があります。
-
リクエストを行うたびにトークンを手動で渡す
これは最も簡単な解決策ですが、すぐに不便になり、厳密な型付けがない場合は忘れやすくなります。この解決策は、Shared層のAPIクライアントのミドルウェアパターンとも互換性がありません。 -
コンテキストや
localStorage
のようなグローバルストレージを介してアプリ全体にトークンへのアクセスを提供する
トークンを取得するためのキーはshared/api
に保存され、APIクライアントがそれを使用できるようにします。トークンのリアクティブストアはユーザーエンティティからエクスポートされ、必要に応じてコンテキストプロバイダーがApp層で設定されます。これにより、APIクライアントの設計に対する自由度が増しますが、このアプローチは暗黙の依存関係を生み出してしまいます。 -
トークンが変更されるたびにAPIクライアントにトークンを挿入する
リアクティブなストアであれば、変更を監視し、ユーザーエンティティのストアが変更されるたびにAPIクライアントのトークンを更新できます。この解決策は、前の解決策と同様に暗黙の依存関係を生み出してしまいますが、より命令的(「プッシュ」)であり、前のものはより宣言的(「プル」)です。
ユーザーエンティティのモデルに保存されたトークンの可用性の問題を解決したら、トークン管理に関連する追加のビジネスロジックを記述できます。例えば、model
セグメントには、トークンを一定期間後に無効にするロジックや、期限切れのトークンを更新するロジックを含めることができます。これらのタスクを実行するために、ユーザーエンティティのapi
セグメント、またはshared/api
を使用します。
Pages層/Widgets層に保存する(非推奨)
アクセストークンのようなアプリ全体に関連する状態をページやウィジェットに保存することは推奨されません。ログインページのmodel
セグメントにトークンストレージを配置しないでください。代わりに、最初の2つの解決策(Shared層配置かEntities層配置)のいずれかを選択してください。
ログアウトとトークンの無効化
通常、アプリケーションではログアウト専用のページを作成しませんが、ログアウト機能は非常に重要です。この機能には、バックエンドへの認証リクエストとトークンストレージの更新が含まれます。
すべてのリクエストをshared/api
に保存している場合は、ログイン関数の近くにログアウトリクエストの関数を配置してください。そうでない場合は、ログアウトを呼び出すボタンの近くに配置してください。例えば、すべてのページに存在し、ログアウトリンクを含むヘッダーウィジェットがある場合、そのリクエストをこのウィジェットのapi
セグメントに配置します。
トークンストレージの更新も、ログアウトボタンの場所からトリガーされる必要があります。リクエストとストレージの更新をこのウィジェットのmodel
セグメントで統合できます。
自動ログアウト
ログアウトリクエストやトークン更新リクエストの失敗を考慮することを忘れないでください。いずれの場合も、トークンストレージをリセットする必要があります。トークンをEntities層に保存している場合、このコードはmodel
セグメントに配置できます。トークンをShared層に保存している場合、このロジックをshared/api
に配置すると、セグメントが膨らみ、その目的が曖昧になってしまいます。api
セグメントに無関係な2つのものが含まれていることに気づいた場合、トークン管理ロジックを別のセグメント、例えばshared/auth
に分離することを検討してみてください。