Closed4

Next.jsの理解を深める!Chapter 15

nakamotonakamoto

Chapter 15(first)

Authentication vs. Authorization
In web development, authentication and authorization serve different roles:

Authentication is about making sure the user is who they say they are. You're proving your identity with something you have like a username and password.
Authorization is the next step. Once a user's identity is confirmed, authorization decides what parts of the application they are allowed to use.
So, authentication checks who you are, and authorization determines what you can do or access in the application.

  • ウェブ開発における認証認可は異なる役割を果たす。認証はユーザーが自分の主張する通りの人物であることを確認する。ユーザー名とパスワードのような何かを持って身元を証明する。認可は次のステップ。ユーザーの身元が確認されたら、認可はそのユーザーがアプリケーションのどの部分を使用できるかを決定する。つまり、認証はあなたが誰であるかをチェックし認可はアプリ内であなたが何ができるか、またはアクセスできるかを決定する。

NextAuth.js
We will be using NextAuth.js to add authentication to your application. NextAuth.js abstracts away much of the complexity involved in managing sessions, sign-in and sign-out, and other aspects of authentication. While you can manually implement these features, the process can be time-consuming and error-prone. NextAuth.js simplifies the process, providing a unified solution for auth in Next.js applications.

  • NextAuth.jsはNext.jsアプリケーションに認証機能を追加するために使用される。NextAuth.jsはセッション管理、サインイン及びサインアウト、その他の認証関連の複雑さを抽象化し、
    単純化する。これらの機能を手動で実装することもできるが、そのプロセスは時間がかかる。NextAuth.jsはNext.jsアプリケーションのための統一された認証ソリューションを提供し、
    プロセスを簡素化する。

Protecting your routes with Next.js Middleware
Next, add the logic to protect your routes. This will prevent users from accessing the dashboard pages unless they are logged in.

/middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export default NextAuth(authConfig).auth;
 
export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
  • Middlewareにはユーザーが何かしらのリクエストを行ったときにNext.jsアプリケーションが反応する前にどういう処理を行いたいかみたいなのを記述できる。但しユーザーからのリクエスト全てに対しMiddleware挟むというのはリソースも食うしよろしくない。じゃあどういうリクエストに対しMiddleware挟みますかという役割がmatcherになってる。MiddlewareVercelが勝手にEdge Functionsにデプロイしてくれる。Edge FunctionsはWeb標準のAPIは動くけれどもNode.jsが動く環境ではない。Node.jsにはNode.jsという言語の環境が必要となる。

Password hashing
It's good practice to hash passwords before storing them in a database. Hashing converts a password into a fixed-length string of characters, which appears random, providing a layer of security even if the user's data is exposed.

In your seed.js file, you used a package called bcrypt to hash the user's password before storing it in the database. You will use it again later in this chapter to compare that the password entered by the user matches the one in the database. However, you will need to create a separate file for the bcrypt package. This is because bcrypt relies on Node.js APIs not available in Next.js Middleware.

  • DBにパスワードを保存する前にbcryptなどを使ってパスワードをハッシュ化すべし。ハッシュ化はパスワードを固定長の文字列に変換し、ランダムに見えるようにして、ユーザーのデータが露出した場合でも安全性を提供する。
nakamotonakamoto

Chapter 15(second)

Adding the sign in functionality
You can use the authorize function to handle the authentication logic. Similarly to Server Actions, you can use zod to validate the email and password before checking if the user exists in the database:

After validating the credentials, create a new getUser function that queries the user from the database.

Then, call bcrypt.compare to check if the passwords match:

Finally, if the passwords match you want to return the user, otherwise, return null to prevent the user from logging in.

auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
 
async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * FROM users WHERE email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
 
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
          const passwordsMatch = await bcrypt.compare(password, user.password);
 
          if (passwordsMatch) return user;
        }
 
        console.log('Invalid credentials');
        return null;
      },
    }),
  ],
});
  • サインイン機能を追加する際、認証ロジックを処理するためにauthorize関数を使用できる。Server Actionsと同様に、Zodライブラリを使用してメールアドレスとパスワードを検証し、ユーザーがDBに存在するかどうかを確認する。認証情報の検証後、DBからユーザーをクエリするgetUser関数を作成する。その後、bcrypt.compareを呼び出してパスワードが一致するかどうか確認する。最終的にパスワードが一致した場合はユーザーを返し、そうでない場合はnullを返してユーザーがログインできないようにする。

Updating the login form
Now you need to connect the auth logic with your login form. In your actions.ts file, create a new action called authenticate. This action should import the signIn function from auth.ts:

If there's a 'CredentialsSignin' error, you want to show an appropriate error message. You can learn about NextAuth.js errors in the documentation

/app/lib/actions.ts
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
 
// ...
 
export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error;
  }
}
  • ログインフォームを認証ロジックと接続する必要がある。actions.tsファイルで、authenticateという新しいアクションを作成する。このアクションはauth.tsからsignIn関数をインポートする必要がある。CredentialsSigninエラーが発生したら、適切なエラーメッセージを表示する必要がある。NextAuth.jsのエラーは公式ドキュメントで詳細を確認できる。この手順によりログインフォームのユーザー入力が認証システムと適切に連携し、エラー処理が行われる。

  • errorは基本的にunknown型なので何のエラーかを特定するためにinstanceofがよく使われる。

nakamotonakamoto

Chapter 15(third)

Finally, in your login-form.tsx component, you can use React's useFormState to call the server action and handle form errors, and use useFormStatus to handle the pending state of the form:

app/ui/login-form.tsx
'use client';
 
import { lusitana } from '@/app/ui/fonts';
import {
  AtSymbolIcon,
  KeyIcon,
  ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@/app/ui/button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/app/lib/actions';
 
export default function LoginForm() {
  const [errorMessage, dispatch] = useFormState(authenticate, undefined);
 
  return (
    <form action={dispatch} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-3 text-2xl`}>
          Please log in to continue.
        </h1>
        <div className="w-full">
          <div>
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="email"
            >
              Email
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="email"
                type="email"
                name="email"
                placeholder="Enter your email address"
                required
              />
              <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <div className="mt-4">
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="password"
            >
              Password
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="password"
                type="password"
                name="password"
                placeholder="Enter password"
                required
                minLength={6}
              />
              <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
        </div>
        <LoginButton />
        <div
          className="flex h-8 items-end space-x-1"
          aria-live="polite"
          aria-atomic="true"
        >
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}
 
function LoginButton() {
  const { pending } = useFormStatus();
 
  return (
    <Button className="mt-4 w-full" aria-disabled={pending}>
      Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}
  • login-form.tsxコンポーネントはuseFormState を使用してサーバーアクションを呼び出し、フォームのエラーを処理できる。またuseFormStatusを使ってフォームの処理中の状態を扱える。これによりフォームの送信を管理し、ユーザーが送信したデータに基づきサーバー側で何かしらのアクションを起こし、その結果をフォームに反映させることが可能になる。
  • LoginButtonを押してローディング中かどうかをpendingとする。
    pendingならボタンを押せないようになってる。

Adding the logout functionality
To add the logout functionality to <SideNav />, call the signOut function from auth.ts in your <form> element:

/ui/dashboard/sidenav.tsx
import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
import { signOut } from '@/auth';
 
export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      // ...
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        <form
          action={async () => {
            'use server';
            await signOut();
          }}
        >
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}
  • ログアウト機能を<SideNav />に追加するためにはauth.tsからsignOut関数を<form>要素内で呼び出す。これによりユーザーがログアウトボタンをクリックするとsignOut関数が実行され、ユーザーがログアウトされる。
このスクラップは3ヶ月前にクローズされました