15. 인증

이전 장에서는 폼 유효성 검사를 추가하고 접근성을 향상하여 송장 경로 구축을 완료했다. 이 장에서는 대시보드에 인증을 추가한다.

 

이번 장에서 다룰 주제는 아래와 같다.

  • 인증이란?
  • NextAuth.js를 사용하여 앱에 인증을 추가하는 방법.
  • 미들웨어를 사용하여 사용자를 리디렉션하고 경로를 보호하는 방법.
  • React의 useFormStatus 및 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는 세션 관리, 로그인 및 로그아웃, 기타 인증 측면과 관련된 많은 복잡성을 추상화한다. 이러한 기능을 수동으로 구현할 수도 있지만 이 프로세스는 시간이 많이 걸리고 오류가 발생하기 쉽다. NextAuth.js는 Next.js 애플리케이션의 인증을 위한 통합 솔루션을 제공하여 프로세스를 단순화한다.

NextAuth.js 설정

터미널에서 다음 명령을 실행하여 NextAuth.js를 설치하자.

 

Terminal

npm install next-auth@beta

여기서는 Next.js 14와 호환되는 NextAuth.js 베타 버전을 설치한다.

다음으로 애플리케이션에 대한 비밀 키를 생성한다. 이 키는 쿠키를 암호화하여 사용자 세션의 보안을 보장하는 데 사용된다. 터미널에서 다음 명령을 실행하면 된다.

 

Terminal

openssl rand -base64 32

그런 다음 .env 파일에서 생성된 키를 AUTH_SECRET 변수에 추가한다.

 

.env

AUTH_SECRET=your-secret-key

프로덕션에서 인증이 작동하려면 Vercel 프로젝트에서도 환경 변수를 업데이트 해야한다. Vercel에서 환경 변수를 추가하는 방법에 대한 이 가이드를 확인하자.

페이지 옵션 추가

authConfig 객체를 내보내는 프로젝트 루트에 auth.config.ts 파일을 만든다. 이 객체에는 NextAuth.js에 대한 구성 옵션이 포함된다. 지금은 페이지 옵션만 포함된다.

 

/auth.config.ts

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
};

페이지 옵션을 사용하여 사용자 정의 로그인, 로그아웃 및 오류 페이지에 대한 경로를 지정할 수 있다. 이는 필수는 아니지만 페이지 옵션에 signIn: '/login'을 추가하면 사용자는 NextAuth.js 기본 페이지가 아닌 사용자 정의 로그인 페이지로 리디렉션된다.

 

Next.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: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

승인된 콜백은 요청이 Next.js 미들웨어를 통해 페이지에 액세스하도록 승인되었는지 확인하는 데 사용된다. 요청이 완료되기 전에 호출되며 인증 및 요청 속성이 있는 객체를 받게된다. auth 속성에는 사용자의 세션이 포함되고 request 속성에는 들어오는 요청이 포함된다.

공급자 옵션은 다양한 로그인 옵션을 나열하는 배열이다. 현재로서는 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를 초기화하고 인증 속성을 내보내고 있다. 또한 미들웨어의 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에 대한 공급자 옵션을 추가해야 한다. 공급자는 Google 또는 GitHub와 같은 다양한 로그인 옵션을 나열하는 배열이며, 이 과정에서는 자격 증명 공급자만 사용하는 데 중점을 둔다.

자격 증명 공급자를 사용하면 사용자가 사용자 이름과 비밀번호를 사용하여 로그인할 수 있다.

 

/auth.ts

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({})],
});
팁:
자격 증명 공급자를 사용하고 있지만 일반적으로 OAuth 또는 이메일 공급자와 같은 대체 공급자를 사용하는 것이 좋다. 전체 옵션 목록은 NextAuth.js 문서를 참고하자.

 

로그인 기능 추가

권한 부여 기능을 사용하여 인증 논리를 처리할 수 있다. 서버 액과 마찬가지로, 사용자가 데이터베이스에 존재하는지 확인하기 전에 zod를 사용하여 이메일과 비밀번호의 유효성을 검사할 수 있다.

 

/auth.ts

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 함수를 만든다.

 

/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;
        }
 
        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';
 
// ...
 
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 함수를 가져와야 한다.

 

/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;
  }
}

'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';
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';
 
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

'Next.js 개발 가이드 > 06. Learn Next.js 공식 가이드' 카테고리의 다른 글

16. 메타데이터 추가  (0) 2023.12.25
14. 접근성 강화  (0) 2023.12.25
13. 에러 처리  (0) 2023.12.24
12. 데이터 변경  (0) 2023.12.24
11. 검색, 페이지네이션  (0) 2023.12.24
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유