이전 장에서는 양식 유효성 검사를 추가하고 접근성을 향상하여 송장 경로 구축을 완료했습니다. 이 장에서는 대시보드에 인증을 추가하게 됩니다.
useFormStatusReact 및 useFormState를 사용하는 방법.인증은 오늘날 많은 웹 애플리케이션의 핵심 부분입니다. 이는 시스템이 사용자가 자신이 누구인지 확인하는 방법입니다.
보안 웹사이트에서는 사용자의 신원을 확인하기 위해 다양한 방법을 사용하는 경우가 많습니다. 예를 들어 사용자 이름과 비밀번호를 입력하면 사이트에서 기기로 인증 코드를 보내거나 Google Authenticator와 같은 외부 앱을 사용할 수 있습니다. 이 2단계 인증(2FA)은 보안을 강화하는 데 도움이 됩니다. 누군가 귀하의 비밀번호를 알게 되더라도 귀하의 고유 토큰 없이는 귀하의 계정에 접근할 수 없습니다.
웹 개발에서 인증과 권한 부여는 서로 다른 역할을 합니다.
따라서 인증은 귀하가 누구인지 확인하고, 승인은 귀하가 애플리케이션에서 수행할 수 있는 작업이나 액세스할 수 있는 작업을 결정합니다.
먼저 애플리케이션에서 /login이라는 새 경로를 생성하고 다음 코드를 붙여넣으세요.
// /app/login/page.tsx
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는 Next.js 애플리케이션의 인증을 위한 통합 솔루션을 제공하여 프로세스를 단순화합니다.
터미널에서 다음 명령을 실행하여 NextAuth.js를 설치합니다.
npm install next-auth@beta
여기서는 Next.js 14와 호환되는 NextAuth.js beta 버전을 설치합니다 .
다음으로 애플리케이션에 대한 비밀 키를 생성합니다. 이 키는 쿠키를 암호화하여 사용자 세션의 보안을 보장하는 데 사용됩니다. 터미널에서 다음 명령을 실행하면 됩니다.
openssl rand -base64 32
그런 다음 .env 파일에서 생성된 키를 AUTH_SECRET 변수에 추가합니다.
AUTH_SECRET=your-secret-key
프로덕션에서 인증이 작동하려면 Vercel 프로젝트에서도 환경 변수를 업데이트해야 합니다. Vercel에 환경 변수를 추가하는 방법에 대해 이 가이드를 확인해보세요.
프로젝트의 루트에 auth.config.ts 파일을 만들어 authConfig 객체를 내보냅니다. 이 객체에는 NextAuth.js에 대한 구성 옵션이 포함됩니다. 지금은 다음 pages 옵션만 포함됩니다.
// /auth.config.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
};
pages 옵션을 사용하여 사용자 정의 로그인, 로그아웃 및 오류 페이지에 대한 경로를 지정할 수 있습니다. 이는 필수는 아니지만 pages 옵션 안에 signIn: '/login'을 추가하면 사용자는 NextAuth.js 기본 페이지가 아닌 사용자 정의 로그인 페이지로 리디렉션됩니다.
다음으로 경로를 보호하는 로직을 추가합니다. 이렇게 하면 사용자가 로그인하지 않으면 대시보드 페이지에 액세스할 수 없습니다.
// /auth.config.ts
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; // Redirect unauthenticated users to login page
} else if (isLoggedIn) {
return Response.redirect(new URL('/dashboard', nextUrl));
}
return true;
},
},
providers: [], // 일단 빈 배열로 공급자 추가
} satisfies NextAuthConfig;
authorized 콜백은 요청이 Next.js 미들웨어를 통해 페이지에 액세스할 수 있는 권한이 있는지 확인하는 데 사용됩니다. 요청이 완료되기 전에 호출되며 auth 및 request 속성이 있는 객체를 받습니다. auth 속성에는 사용자의 세션이 포함되고 request 속성에는 수신 요청이 포함됩니다.
providers 옵션은 다양한 로그인 옵션을 나열하는 배열입니다. 현재로서는 NextAuth 구성을 충족하기 위한 빈 배열입니다. 자격 증명 공급자 추가 섹션에서 이에 대해 자세히 알아볼 수 있습니다.
다음으로 authConfig 개체를 미들웨어 파일로 가져와야 합니다. 프로젝트 루트에서 middleware.ts라는 파일을 만들고 다음 코드를 붙여넣습니다.
// /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$).*)'],
};
여기서는 authConfig 객체로 NextAuth.js를 초기화하고 auth 속성을 내보내고 있습니다. 또한 미들웨어의 matcher 옵션을 사용하여 특정 경로에서 실행되도록 지정하고 있습니다.
이 작업에 미들웨어를 사용하면 미들웨어가 인증을 확인할 때까지 보호된 경로가 렌더링을 시작하지 않아 애플리케이션의 보안과 성능이 모두 향상된다는 이점이 있습니다.
비밀번호를 데이터베이스에 저장하기 전에 해시하는 것이 좋습니다. 해싱은 비밀번호를 무작위로 나타나는 고정 길이의 문자열로 변환하여 사용자 데이터가 노출되더라도 보안 계층을 제공합니다.
seed.js 파일에서 사용자의 비밀번호를 데이터베이스에 저장하기 전에 해시하도록 bcrypt라는 패키지를 사용했습니다. 이 장의 뒷부분에서 다시 사용하여 사용자가 입력한 비밀번호가 데이터베이스에 있는 비밀번호와 일치하는지 비교할 것입니다. 그러나 bcrypt 패키지에 대해 별도의 파일을 생성해야 합니다. 이는 bcrypt가 Next.js 미들웨어에서 사용할 수 없는 Node.js API에 의존하기 때문입니다.
authConfig 개체를 확산하는 auth.ts라는 새 파일을 만듭니다.
// /auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
});
다음으로 NextAuth.js에 대한 providers 옵션을 추가해야 합니다. providers는 Google 또는 GitHub와 같은 다양한 로그인 옵션을 나열하는 배열입니다. 이 과정에서는 오직 자격 증명 공급자를 사용하는 데 중점을 둡니다.
자격 증명 공급자를 사용하면 사용자가 사용자 이름과 비밀번호를 사용하여 로그인할 수 있습니다.
// /auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials'; // Credentials 불러오기
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [Credentials({})], // 옵션 추가
});
알아두면 좋은 점:
자격 증명 공급자를 사용하고 있지만 일반적으로 OAuth 또는 이메일 공급자와 같은 대체 공급자를 사용하는 것이 좋습니다. 전체 옵션 목록을 보려면 NextAuth.js 문서를 참조하세요.
authorize 함수를 사용하여 인증 로직을 처리할 수 있습니다. 서버 작업과 마찬가지로 사용자가 데이터베이스에 존재하는지 확인하기 전에 이메일과 비밀번호의 유효성을 검사하는 데 zod를 사용할 수 있습니다.
// /auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod'; // 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 함수를 만듭니다.
// /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'; // sql 불러오기
import type { User } from '@/app/lib/definitions'; // user 불러오기
// getUser 함수 만들기
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를 호출하여 비밀번호가 일치하는지 확인하세요.
// /auth.ts
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'; // 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을 반환합니다.
이제 인증 로직을 로그인 양식과 연결해야 합니다. actions.ts파일에서 authenticate라는 새 작업을 만듭니다. 이 작업은 auth.ts에서 signIn 함수를 가져와야 합니다.
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' 오류가 있는 경우 적절한 오류 메시지를 표시하려고 합니다. 설명서에서 NextAuth.js 오류에 대해 알아볼 수 있습니다.
마지막으로 login-form.tsx 구성 요소에서 React의 useFormState를 사용하여 서버 작업을 호출하고 양식 오류를 처리하고, 양식의 보류 상태를 처리하는 데 useFormStatus를 사용할 수 있습니다.
// 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'; // state, status 불러오기
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>
);
}
<SideNav />에 로그아웃 기능을 추가하려면 <form> 요소에서 auth.ts의 signOut 함수를 호출하세요.
// /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'; // signOut 불러오기
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.com123456