9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js で認証を 実装する方法

Posted at

Next.jsで認証を実装する方法

(日本語が得意ではないため、AIツールを使用して翻訳しています。ご不便をおかけして申し訳ありません :bow_tone1:)

目次

はじめに

こんにちは、TOMOSIA VIETNAMのエンジニアです。
Next.jsを使用した多くのプロジェクトを経験しましたが、どのプロジェクトにもログインや登録などの画面がほぼ必ず存在します。そこで、これらの画面のテンプレートを作成し、他のプロジェクトで再利用することで時間を節約しようと考えました。

結果:

  • ログイン、登録などの画面のテンプレートを作成
  • 他のプロジェクトでの再利用により、開発時間を70%以上短縮
  • バグの数を50%以上削減
  • 後続の開発者が容易に使用できるように詳細なドキュメントを作成

詳細情報: https://auth-template-lime.vercel.app/doc
GitHubリポジトリ: https://github.com/HieuNT44/auth-template/tree/main
参考資料: https://nextjs.org/docs/app/guides/authentication

そして、フロー設計に関連する中核部分である「Authentication (NextAuthを使用)」について、皆さんに紹介したいと思います。

認証を理解することは、アプリケーションのデータを保護するために重要です。このページでは、認証を実装するために使用するReactとNext.jsの機能について説明します。

始める前に、プロセスを3つの概念に分解すると役立ちます:

  1. 認証 (Authentication): ユーザーが本人であることを確認します。これには、ユーザー名とパスワードなど、ユーザーが持っている何かで身元を証明する必要があります。
  2. セッション管理 (Session Management): リクエスト間でユーザーの認証状態を追跡します。
  3. 認可 (Authorization): ユーザーがアクセスできるルートとデータを決定します。

この図は、ReactとNext.jsの機能を使用した認証フローを示しています:

ReactとNext.jsの機能を使用した認証フローを示す図

このページの例では、教育目的で基本的なユーザー名とパスワードによる認証を説明します。独自の認証ソリューションを実装することもできますが、セキュリティと簡素化のために、認証ライブラリを使用することをお勧めします。これらは、認証、セッション管理、認可のための組み込みソリューションを提供するだけでなく、ソーシャルログイン、多要素認証、ロールベースのアクセス制御などの追加機能も提供します。

認証

サインアップとログイン機能

React Hook FormとZodを使用して<form>要素を使用し、ユーザーの認証情報を取得し、フォームフィールドを検証し、認証プロバイダーのAPIを呼び出すことができます。

サインアップ/ログイン機能を実装する手順は次のとおりです:

1. ユーザー認証情報の取得

ユーザー認証情報を取得するには、ShadcnUIコンポーネントとReact Hook Formを使用するフォームを作成します:

// src/features/auth/components/login-form.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";

export function LoginForm() {
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: "", password: "" },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>{t("email")}</FormLabel>
              <FormControl>
                <Input {...field} type="email" />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>{t("password")}</FormLabel>
              <FormControl>
                <PasswordInput {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={formState.isLoading}>
          {t("submit")}
        </Button>
      </form>
    </Form>
  );
}

2. サーバーでのフォームフィールドの検証

Zodスキーマを使用してフォームフィールドを検証します。i18nを使用して適切なエラーメッセージを持つフォームスキーマを定義します:

// src/features/auth/components/login-form.tsx
const loginSchema = z.object({
  email: z.string().email(tValidation("email")),
  password: z.string().min(1, tValidation("required", { field: t("password") })),
});

type LoginFormData = z.infer<typeof loginSchema>;

認証プロバイダーのAPIへの不要な呼び出しを防ぐため、フォーム検証は最初にzodResolverを介してクライアントで行われます。

3. ユーザーの作成または認証情報の確認

フォームフィールドを検証した後、NextAuth.jsのsignInを呼び出してユーザーが存在するかどうかを確認できます:

// src/features/auth/components/login-form.tsx
async function onSubmit(data: LoginFormData) {
  formState.startSubmit();

  try {
    const result = await signIn("credentials", {
      email: data.email,
      password: data.password,
      redirect: false,
    });

    if (result?.error) {
      formState.setError(tErrors("invalidCredentials"));
    } else {
      router.push("/dashboard");
      router.refresh();
    }
  } catch {
    formState.setError(tErrors("unknownError"));
  } finally {
    formState.setLoading(false);
  }
}

ユーザー認証情報が正常に確認されると、NextAuth.jsは自動的にセッションを作成します。詳細については、セッション管理 セクションに進んでください。

ヒント:

  • プロセスを簡素化するために、NextAuth.jsのような認証ライブラリの使用を検討してください。
  • ユーザーエクスペリエンスを向上させるために、登録フローの早い段階で重複するメールアドレスを確認することをお勧めします。

セッション管理

セッション管理により、リクエスト間でユーザーの認証状態が保持されます。これには、セッションまたはトークンの作成、保存、更新、削除が含まれます。

セッションには2つのタイプがあります:

  1. ステートレス (Stateless): セッションデータ(またはトークン)はブラウザのCookieに保存されます。Cookieは各リクエストと共に送信され、サーバー上でセッションを検証できます。この方法はシンプルですが、正しく実装しないとセキュリティが低下する可能性があります。
  2. データベース (Database): セッションデータはデータベースに保存され、ユーザーのブラウザは暗号化されたセッションIDのみを受け取ります。この方法はより安全ですが、複雑になり、より多くのサーバーリソースを使用する可能性があります。

知っておくと良いこと: セッション管理を自動的に処理するNextAuth.jsの使用をお勧めします。

ステートレスセッション

NextAuth.jsはデフォルトでCookieに保存されたJWTを使用します。ステートレスセッションを作成および管理するには:

1. 秘密鍵の生成

セッションに署名するための秘密鍵を生成します:

openssl rand -base64 32

環境変数ファイルに保存します:

# .env.local
NEXTAUTH_SECRET=your_secret_key
NEXTAUTH_URL=http://localhost:3000

2. セッションの暗号化と復号化

NextAuth.jsはJWTの暗号化/復号化を自動的に処理します:

// src/core/lib/auth.ts
import NextAuth, { NextAuthOptions } from "next-auth";

export const authOptions: NextAuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  session: {
    strategy: "jwt",
    maxAge: 7 * 24 * 60 * 60, // 7 days
  },
};

3. Cookieの設定(推奨オプション)

NextAuth.jsは、httpOnly、secure、singleSiteオプションを使用して安全なCookieを自動的に設定します:

// src/core/lib/auth.ts
export const authOptions: NextAuthOptions = {
  cookies: {
    sessionToken: {
      name: "next-auth.session-token",
      options: {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        path: "/",
      },
    },
  },
};

セッションの更新(またはリフレッシュ)

コールバックを使用してセッションの有効期限を延長できます:

// src/core/lib/auth.ts
callbacks: {
  async jwt({ token, user }) {
    if (user) {
      token.id = user.id;
      token.role = user.role;
    }
    return token;
  },
  async session({ session, token }) {
    session.user.id = token.id as string;
    session.user.role = token.role as string;
    return session;
  },
},

セッションの削除

セッションを削除するには、NextAuth.jsのsignOutを使用します:

// src/features/auth/components/logout-button.tsx
"use client";

import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { LogOut } from "lucide-react";

export function LogoutButton() {
  const [isLoading, setIsLoading] = useState(false);

  const handleLogout = async () => {
    setIsLoading(true);
    await signOut({ callbackUrl: "/login" });
  };

  return (
    <Button onClick={handleLogout} disabled={isLoading}>
      <LogOut className="mr-2 h-4 w-4" />
      {t("logout")}
    </Button>
  );
}

データベースセッション

JWTの代わりにデータベースセッションを使用するには、アダプターを構成します:

// src/core/lib/auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/core/lib/prisma";

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: "database",
  },
};

認可

ユーザーが認証され、セッションが作成されると、アプリケーション内でユーザーがアクセスできるものと実行できることを制御するための認可を実装できます。

認可チェックには主に2つのタイプがあります:

  1. 楽観的 (Optimistic): Cookieに保存されたセッションデータを使用して、ユーザーがルートにアクセスする権限があるか、アクションを実行する権限があるかを確認します。これらのチェックは、UI要素の表示/非表示や、権限やロールに基づくユーザーのリダイレクトなど、迅速な操作に役立ちます。
  2. 安全 (Secure): データベースに保存されたセッションデータを使用して、ユーザーがルートにアクセスする権限があるか、アクションを実行する権限があるかを確認します。これらのチェックはより安全であり、機密データやアクションへのアクセスを必要とする操作に使用されます。

どちらの場合も、以下をお勧めします:

Proxyを使用した楽観的なチェック(オプション)

権限に基づいてProxyを使用してユーザーをリダイレクトしたい場合があります:

  • 楽観的なチェックを実行するため。Proxyはすべてのルートで実行されるため、リダイレクトロジックを一元化し、承認されていないユーザーを事前にフィルタリングするのに適した方法です。
  • ユーザー間でデータを共有する静的ルート(ペイウォールの背後にあるコンテンツなど)を保護するため。

ただし、Proxyはプリフェッチされたルートを含むすべてのルートで実行されるため、パフォーマンスの問題を防ぐために、Cookieからのみセッションを読み取り(楽観的なチェック)、データベースチェックを回避することが重要です。

// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

const protectedRoutes = ["/dashboard", "/settings"];
const authRoutes = ["/login", "/register", "/forgot-password"];

export default async function proxy(req: NextRequest) {
  const token = await getToken({ req });
  const { pathname } = req.nextUrl;

  // Redirect to login if not authenticated
  if (protectedRoutes.some((r) => pathname.startsWith(r)) && !token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  // Redirect to dashboard if already authenticated
  if (authRoutes.includes(pathname) && token) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Proxyは最初のチェックには役立ちますが、データを保護するための唯一の防衛線であってはなりません。セキュリティチェックの大部分は、データソースのできるだけ近くで実行する必要があります。詳細については、データアクセスレイヤーを参照してください。

データアクセスレイヤー (DAL) の作成

データリクエストと認可ロジックを一元化するためにDALを作成することをお勧めします。

DALには、ユーザーがアプリケーションと対話する際にセッションを検証する機能を含める必要があります。少なくとも、この機能はセッションが有効かどうかを確認し、リダイレクトするか、さらなるリクエストを行うために必要なユーザー情報を返す必要があります。

// src/core/lib/dal.ts
import "server-only";

import { cache } from "react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "./auth";

export const verifySession = cache(async () => {
  const session = await getServerSession(authOptions);

  if (!session?.user) {
    redirect("/login");
  }

  return session;
});

export const getUser = cache(async () => {
  const session = await verifySession();

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, email: true, role: true },
  });

  return user;
});

その後、Server ComponentsでverifySession()関数を呼び出すことができます:

// app/[locale]/(dashboard)/dashboard/page.tsx
import { verifySession } from "@/core/lib/dal";

export default async function DashboardPage() {
  const session = await verifySession();

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
    </div>
  );
}

ヒント:

  • DALを使用すると、リクエスト時に取得されるデータを保護できます。ただし、ユーザー間でデータを共有する静的ルートの場合、データはリクエスト時ではなくビルド時に取得されます。静的ルートを保護するにはProxyを使用してください。
  • 安全なチェックのために、セッションIDをデータベースと比較してセッションが有効かどうかを確認できます。Reactのcache関数を使用して、レンダリングパス中のデータベースへの不要な重複リクエストを回避してください。

データ転送オブジェクト (DTO) の使用

データを取得する際は、オブジェクト全体ではなく、アプリケーションで使用される必要なデータのみを返すことをお勧めします。たとえば、ユーザーデータを取得する場合、パスワードや電話番号などを含むユーザーオブジェクト全体ではなく、ユーザーのIDと名前のみを返すようにします。

ただし、返されるデータ構造を制御できない場合や、オブジェクト全体がクライアントに渡されるのを避けたいチームで作業している場合は、クライアントに公開しても安全なフィールドを指定するなどの戦略を使用できます。

// src/core/lib/dto.ts
import "server-only";
import { getUser } from "@/core/lib/dal";

function canSeeUsername(viewer: User) {
  return true;
}

function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team;
}

export async function getProfileDTO(slug: string) {
  const data = await db.user.findMany({
    where: { slug },
  });
  const user = data[0];

  const currentUser = await getUser(user.id);

  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  };
}

データリクエストと認可ロジックをDALに一元化し、DTOを使用することで、すべてのデータリクエストが安全で一貫していることを確認でき、アプリケーションの拡張に伴う保守、監査、デバッグが容易になります。

知っておくと良いこと:

  • DTOを定義する方法はいくつかあります。toJSON()の使用から、上記の例のような個別の関数、またはJSクラスまで様々です。これらはReactやNext.jsの機能ではなくJavaScriptのパターンであるため、アプリケーションに最適なパターンを見つけるために調査することをお勧めします。

Server Components

Server Componentsでの認証チェックは、ロールベースのアクセスに役立ちます。たとえば、ユーザーのロールに基づいてコンポーネントを条件付きでレンダリングする場合などです:

// app/[locale]/(dashboard)/dashboard/page.tsx
import { verifySession } from "@/core/lib/dal";

export default async function Dashboard() {
  const session = await verifySession();
  const userRole = session?.user?.role;

  if (userRole === "admin") {
    return <AdminDashboard />;
  } else if (userRole === "user") {
    return <UserDashboard />;
  } else {
    redirect("/login");
  }
}

この例では、DALのverifySession()関数を使用して、「admin」、「user」、および未承認のロールを確認しています。このパターンにより、各ユーザーは自分のロールに適したコンポーネントとのみ対話することが保証されます。

レイアウトと認証チェック

部分レンダリング (Partial Rendering) のため、レイアウトでチェックを行う場合は注意が必要です。これらはナビゲーション時に再レンダリングされないため、ルート変更ごとにユーザーセッションがチェックされないことを意味します。

代わりに、データソースまたは条件付きでレンダリングされるコンポーネントの近くでチェックを行う必要があります。

たとえば、ユーザーデータを取得し、ナビゲーションにユーザー画像を表示する共有レイアウトを考えてみましょう。レイアウトで認証チェックを行う代わりに、レイアウトでユーザーデータ(getUser())を取得し、DALで認証チェックを行う必要があります。

これにより、アプリケーション内でgetUser()が呼び出される場所であればどこでも認証チェックが実行されることが保証され、開発者がユーザーがデータにアクセスする権限を持っているかどうかを確認し忘れるのを防ぎます。

ページコンポーネントでの認証チェック

たとえば、ダッシュボードページでは、ユーザーセッションを確認し、ユーザーデータを取得できます:

// app/[locale]/(dashboard)/dashboard/page.tsx
import { verifySession } from "@/core/lib/dal";

export default async function DashboardPage() {
  const session = await verifySession();

  const user = await getUserData(session.userId);

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
    </div>
  );
}

リーフコンポーネントでの認証チェック

ユーザーの権限に基づいてUI要素を条件付きでレンダリングするリーフコンポーネントで認証チェックを実行することもできます。たとえば、管理者のみのアクションを表示するコンポーネントなどです:

// src/features/admin/components/admin-actions.tsx
import { verifySession } from "@/core/lib/dal";
import { Button } from "@/components/ui/button";

export default async function AdminActions() {
  const session = await verifySession();
  const userRole = session?.user?.role;

  if (userRole !== "admin") {
    return null;
  }

  return (
    <div className="flex gap-2">
      <Button variant="destructive">Delete User</Button>
      <Button>Edit Settings</Button>
    </div>
  );
}

このパターンを使用すると、各コンポーネントのレンダリング時に認証チェックが行われることを保証しながら、ユーザーの権限に基づいてUI要素を表示または非表示にすることができます。

知っておくと良いこと:

  • SPAでの一般的なパターンは、ユーザーが承認されていない場合にレイアウトまたはトップレベルコンポーネントでreturn nullすることです。Next.jsアプリケーションには複数のエントリポイントがあるため、このパターンは推奨されません。ネストされたルートセグメントやServer Actionsへのアクセスを防ぐことができないためです。
  • クライアント側のUI制限だけではセキュリティに不十分であるため、これらのコンポーネントから呼び出されるServer Actionsも独自の認可チェックを実行するようにしてください。

Server Actions

Server Actionsは、一般向けのAPIエンドポイントと同じセキュリティ上の考慮事項で扱い、ユーザーがミューテーションを実行することが許可されているかどうかを確認してください。

下の例では、アクションの続行を許可する前にユーザーのロールを確認しています:

// src/features/admin/actions/admin-actions.ts
"use server";
import { verifySession } from "@/core/lib/dal";

export async function deleteUser(formData: FormData) {
  const session = await verifySession();
  const userRole = session?.user?.role;

  if (userRole !== "admin") {
    return { error: "Unauthorized" };
  }

  // Proceed with the action for authorized users
  const userId = formData.get("userId");
  await db.user.delete({ where: { id: userId } });

  return { success: true };
}

Route Handlers

Route Handlersは、一般向けのAPIエンドポイントと同じセキュリティ上の考慮事項で扱い、ユーザーがRoute Handlerにアクセスすることが許可されているかどうかを確認してください。

たとえば:

// app/api/admin/users/route.ts
import { verifySession } from "@/core/lib/dal";

export async function GET() {
  const session = await verifySession();

  if (!session) {
    return new Response(null, { status: 401 });
  }

  if (session.user.role !== "admin") {
    return new Response(null, { status: 403 });
  }

  const users = await db.user.findMany();
  return Response.json(users);
}

上記の例は、2段階のセキュリティチェックを備えたRoute Handlerを示しています。最初にアクティブなセッションを確認し、次にログインしているユーザーが「管理者」であるかどうかを確認します。

Context Providers

認証にコンテキストプロバイダーを使用することは、インターリーブにより機能します。ただし、ReactのcontextはServer Componentsではサポートされていないため、Client Componentsにのみ適用可能です。

これは機能しますが、子Server Componentsは最初にサーバー上でレンダリングされ、コンテキストプロバイダーのセッションデータにアクセスできません:

// app/[locale]/layout.tsx
import { SessionProvider } from "next-auth/react";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}
// src/features/auth/components/user-button.tsx
"use client";

import { useSession } from "next-auth/react";

export function UserButton() {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <Skeleton className="h-8 w-8 rounded-full" />;
  }

  if (!session) {
    return <Link href="/login">Sign In</Link>;
  }

  return <Avatar src={session.user.image} alt={session.user.name} />;
}

Client Componentsでセッションデータが必要な場合(たとえば、クライアント側のデータ取得用)、ReactのtaintUniqueValue APIを使用して、機密性の高いセッションデータがクライアントに公開されるのを防ぎます。

記事をお読みいただきありがとうございます。詳細は以下をご覧ください。
docs
github

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?