前回
Next.jsのチュートリアルをやってみる【アクセシビリティの向上編】はじめに
前回は請求書ルートにあるフォームのバリデーションを追加してアクセシビリティを改善していきました。今回はダッシュボードに認証を追加していきます。
認証とは?
多くのウェブアプリケーションで認証機能は重要な役割を担っています。
安全なウェブサイトではユーザーの身元を確認するために複数の方法を使用することがよくあります。例えば、ユーザー名とパスワードを入力した後、サイトがユーザーのデバイスに認証コードを送信したり、Google Authenticatorのような外部アプリを使用したりします。たとえパスワードを知られたとしても、固有のトークンがなければアカウントにアクセスすることができなくなるので、この2要素認証(2FA)はセキュリティの強化に役立ちます。
認証と認可
ウェブ開発において、認証と認可は異なる役割を果たします。
認証 | ユーザーが本人であることを確認すること。ユーザー名とパスワードのようなもので本人であることを証明する。 |
認可 | 認証のあとに行う。ユーザーの身元が確認された後、アプリケーションのどの部分の使用を許可するかを決める。 |
ログインルートの作成
まずは/login
というルートを作成して、以下のコードを追加しましょう。
import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
export default function LoginPage() {
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
<div className="w-32 text-white md:w-36">
<AcmeLogo />
</div>
</div>
<LoginForm />
</div>
</main>
);
}
<LoginForm />
をインポートして、使用していることに気が付くと思います。
NextAuth.js
NextAuth.jsを使用してアプリケーションに認証を追加します。NextAuth.jsは、セッション管理、サインイン、サインアウトなど、認証に関わる複雑な作業の多くを抽象化してくれます。これらの機能を手動で実装することもできますが、時間がかかりミスが発生しがちです。NextAuth.jsはこれらのプロセスを簡素化し、認証のための統一された機能を提供します。
NextAuth.jsのセットアップ
NextAuth.jsのインストール
NextAuth.js をインストールするためにターミナルで以下のコマンドを実行しましょう。
pnpm i next-auth@beta
チュートリアルの時点ではbeta版が互換性があるとのことなので、合わせてBeta版をインストールしました。
opensslのインストール
チュートリアルではopenssl
コマンドを実行していますが、Windows環境では別途導入する必要があります。
Visual C++ 2017再配布パッケージ のインストール
openssl
コマンドを実行するには事前にVisual C++ 2017 再配布パッケージをインストールする必要があります。
Microsoftの公式ページからダウンロードして、インストールしておきましょう。
windowsOS用opensslのインストール
公式サイトからWindows OS用のインストーラーをダウンロードしましょう。
続いてアプリケーションの秘密鍵を生成するために以下のコマンドを実行しましょう。このキーはクッキーを暗号化して、ユーザーセッションのセキュリティを確保するために使用されます。
openssl rand -base64 32
秘密鍵が生成されたらコピーして、.env
ファイルのAUTH_SECRET=
に張り付けて保存する。
ページ・オプションの追加
プロジェクトのルートにauth.config.ts
ファイルを作成して、NextAuth.js
の設定オプションが含まれまれた、authConfig
オブジェクトをエクスポートします。今のところ以下の様にpage
オプションだけが格納されています。
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
} satisfies NextAuthConfig;
pages
オプションを利用すると、カスタムサインイン、サインアウト、エラーページのルートを指定できます。必須ではありませんが、pages
オプションにsignIn: '/login'
を追加することで、ユーザーはNextAuth.jsのデフォルトページではなく、カスタムログインページにリダイレクトすることができます。
NextAuth.jsのミドルウェアでルートを保護する
続いて、ルートを保護するロジックを追加します。ログインしていないユーザーがダッシュボードのページにアクセスできないようにしましょう。
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // 認証されていないユーザーをログインページにリダイレクトする
} else if (isLoggedIn){
return Response.redirect(new URL('/dashboard', nextUrl));
}
return true;
},
},
providers: [], // とりあえず空の配列でプロパイだを追加
} satisfies NextAuthConfig;
authorized
コールバックは、リクエストがNext.jsミドルウェア経由でページにアクセスすることを許可されているかどうかを確認するために使用されます。リクエストが完了する前に呼び出され、auth
プロパティとrequest
プロパティを持つオブジェクトを受け取ります。auth
プロパティにはユーザーのセッションが含まれ、request
プロパティには入力されたリクエストが含まれます。
porvider
オプションは、様々なログインオプションを列挙する配列です。現時点では、NextAuthの設定を満たすために空の配列を渡しています。詳細は後述します。
次は、authConfig
オブジェクトをミドルウェアファイルにインポートする必要があります。プロジェクトのルートに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$).*)'],
};
このコードではNextAuth.js
をauthConfig
オブジェクトで初期化し、auth
プロパティをエクスポートしています。また、ミドルウェアのmatcher
オプションを使用して、特定のパスで実行するように指定しています。
このタスクにミドルウェアを採用する利点はミドルウェアが認証を検証するまで保護されたルートはレンダリングを開始しないため、アプリケーションのセキュリティとパフォーマンスが向上することです。
パスワードハッシュ
パスワードをデータベースに保存する前にハッシュ化しましょう。葉shスカはパスワードを固定長の文字列に変換し、ランダムに見えるようにします。
seed.js
ファイルではユーザーのパスワードをデータベースに保存する前のハッシュ化するためにvcrypt
というパッケージを使用しました。後ほどユーザーが入力したパスワードがデータベースのパスワードと一致するかを比較するために再度使用します。ただし、bcrypt
パッケージ用に別のファイルを作成する必要があります。これは、bcrypt
がNext.jsミドルウェアでは利用できないNode.js APIに依存しているためです。
新しくauth.ts
ファイルを作成してauthConfig
オブジェクトを作成していきます。
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
});
認証情報プロパイダーの追加
次はNextAuth.jsのproviders
オプションを追加しましょう。providers
はGoogleやGitHubなどのログインオプションを列挙する配列です。今回はCredentials providerのみを使用して進めます。
Credentials provider はユーザーがユーザー名とパスワードでログインできるようにします。
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [Credentials({})],
});
サインイン機能の追加
認証ロジックを処理するには、authorize
関数を使用します。サーバー・アクションと同様にzod
を使用してメールとパスワードを検証してから、そのユーザーがデータベースに存在するかどうかをチェックします。
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
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);
},
}),
],
});
認証情報を検証した後、データベースからユーザーを問い合わせる新しいgetUser
関数を作成します。
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;
}
return null;
},
}),
],
});
次に、bcrypt.compare
を呼び出してパスワードが一致するかをチェックします。
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { sql } from '@vercel/postgres';
import { z } from 'zod';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
// ...
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(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;
},
}),
],
});
最後に、パスワードが一致すればユーザーを返し、一致しなければnullを返してユーザーがログインできないようにします。
ログインフォームの更新
続いて、認証ロジックをログインフォームに追加する必要があります。action.ts
ファイルにauthenticate
という新しいアクションを作成します。このアクションは、auth.ts
からsignIn
関数をインポートする必要があります。
'use server';
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;
}
}
CredentialsSignin
エラーが発生した場合は適切なエラーメッセージを表示します。エラーはこちらを参照してください。
最後にlogin-form.tsx
コンポーネントでReactのuseActionStateを使用して、サーバーアクションを呼び出し、フォームエラーを処理し、フォームの保留状態を表示することができます。
'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 { useActionState } from 'react';
import { authenticate } from '@/app/lib/actions';
export default function LoginForm() {
const [errorMessage, formAction, isPending] = useActionState(
authenticate,
undefined,
);
return (
<form action={formAction} 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>
<Button className="mt-4 w-full" aria-disabled={isPending}>
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
</Button>
<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>
);
}
ログアウト機能の追加
<SideNav />
にログアウト機能を追加するには、<form>
要素内でauth.ts
のsignOut
関数を呼び出します。
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>
);
}
ログイン確認
それでは、以下の認証情報を使用してログイン・ログアウトを確認してみましょう。
トップページからログインボタンを押下して以下のログインページに遷移します。ログイン後ダッシュボードページが表示されたら成功です。
- メールアドレス:
user@nextmail.com
- パスワード:
123456
まとめ
- NextAuth.js は認証回りの様々な機能を提供している
providers
オプションはGoogleやGitHubなどログインオプションに使用する
- パスワードはデータベースに平文ではなくハッシュ化してから保存する
bcrypt
パッケージはハッシュ化やパスワード比較に使用できる- opensslコマンドを使用して秘密鍵を生成することができる
- windowsの場合はインストールが必要
- パッケージによってはNode.js APIに依存しているため別ファイルに分離する必要がある