티스토리 뷰


15. 인증 추가

대시보드에 인증을 추가한다.

 

15-1. 인증이란?

시스템이 유저 자신이 누구인지 확인하는 방법

보안 웹사이트에서는 유저의 신원을 확인하기 위해 다양한 방법을 사용한다.

유저 이름과 비밀번호를 입력하면 사이트에서 기기로 인증 코드를 보내거나 Google Authenticator과 같은 외부 앱을 사용한다.

이는 2단계 인증(2FA)은 보안을 강화하는 데 도움이 된다. 누군가 유저의 비밀번호를 알게 되더라도 해당 유저의 고유 토큰 없이는 계정에 접근할 수 없다.

 

💜 Authentication(인증) vs Authorization(승인)

웹 개발에서 인증과 권한 부여는 서로 다른 역할을 한다.

  • 인증 : 사용자가 누구인지 확인하는 것
    • 사용자 이름과 비밀번호 등 사용자가 가지고 있는 정보를 통해 사용자의 신원을 증명
  • 승인 : 인증 다음 단계
    • 사용자의 신원이 확인되면 승인을 통해 사용자가 사용할 수 있는 애플리케이션 부분이 결정됨
따라서 인증은 사용자가 누구인지 확인하고, 승인은 사용자가 애플리케이션에서 수행할 수 있는 작업이나 액세스할 수 있는 작업을 결정한다.

 

 

 

 

15-2. 로그인 경로 생성하기

/app 디렉토리에 login 경로를 만들고 page.tsx를 작성한다.

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

 

 

 

 

15-3. NextAuth.js

NextAuth.js를 사용해 애플리케이션에 인증을 추가한다.

NextAuth.js는 세션 관리, 로그인 및 로그아웃, 기타 인증 측면과 관련된 많은 복잡성을 추상화한다. 이러한 기능을 수동으로 구현할 수도 있지만 이 프로세스는 시간이 많이 걸리고 오류가 발생하기 쉽다. 

NextAuth.js는 Next.js 애플리케이션의 인증을 위한 통합 솔루션을 제공하여 프로세스를 단순화한다.

 

 

15-4. NextAuth.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 프로젝트에서도 환경 변수를 업데이트해야 한다.

https://vercel.com/docs/projects/environment-variables

 

Environment Variables

Learn how to use Vercel Environment Variables, which are key-value pairs configured outside your source code that change depending on the environment.

vercel.com

 

프로젝트 세팅의 Enviroment Variables 탭으로 들어가 환경 변수에 원하는 이름을 입력한다.

저장하기를 눌러 환경 변수를 저장한다.

 

 

 💜 페이지 옵션 추가

프로젝트 루트에 auth.config.ts 파일을 만들고 authConfig 객체를 내보낸다.

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

이 객체에는 NextAuth.js에 대한 구성 옵션이 포함된다. 지금은 pages 옵션만 포함된다.

 

pages 옵션을 사용해 사용자 정의 로그인, 로그아웃 및 오류 페이지에 대한 경로를 지정할 수 있다.

이는 필수는 아니지만 page 옵션에 signIn: '/login'을 추가하면 사용자는 NextAuth.js 기본 페이지가 아닌 사용자의 로그인 페이지로 리다이렉션된다.

 

 

 

15-5. 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; // 인증되지 않은 사용자는 로그인 페이지로 리다이렉션
      } else if (isLoggedIn) {
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
  providers: [], 
} satisfies NextAuthConfig;

 

승인된 콜백은 요청이 Next.js 미들웨어를 통해 페이지에 액세스하도록 승인되었는지 확인하는데 사용된다.

요청이 완료되기 전에 호출되며 인증 및 요청 속성이 있는 객체를 받는다.

auth 속성에는 사용자의 세션이 포함되고 request 속성에는 들어오는 요청이 포함된다.

 

providers 옵션은 다양한 로그인 옵션을 나열하는 배열이다.

현재는 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$).*)'],
};

authConfig 객체를 사용해 NextAuth.js를 내보내고 있다. 또한 미들웨어의 matcher 옵션을 사용해 특정 경로에서 실행되도록 지정한다.

이 작업에서 미들웨어를 사용하면 미들웨어가 인증을 확인할 때까지 보호된 경로가 렌더링을 시작하지 않아 애플리케이션의 보안과 성능이 모두 향상된다는 이점이 있다.

 

 

💜 비밀번호 해싱

비밀번호를 데이터베이스에 저장하기 전에 해시하는 것이 좋다.

해싱은 비밀번호를 무작위로 나타나는 고정 길이의 문자열로 변환하여 사용자 데이터가 노출되더라도 보안 계층을 제공한다.

 

seed.js 파일에서 비밀번호를 데이터베이스에 저장하기 전에 해시하도록 bcrypt 패키지를 사용한다.

이후에 이를 다시 사용해 사용자가 입력한 비밀번호가 데이터베이스에 있는 비밀번호와 일치하는지 비교한다. 그러나 bcrypt 패키지에 대한 별도의 파일을 생성해야 한다.

 

authConfig 객체를 확산시키는 auth.ts 새 파일을 만든다.

// /auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

 

 

💜 Credentials provider 추가하기

NextAuth.js에 대한 providers 옵션을 추가한다.

providers는 Google 또는 Github과 같은 다양한 로그인 옵션을 나열하는 배열이다. 

Credentials provider를 사용하면 사용자가 사용자 이름과 비밀번호를 사용해 로그인할 수 있다.

// /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({})],
});

 

 

 💜 로그인 기능 추가

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';
 
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;
      },
    }),
  ],
});

비밀번호가 일치하면 user를 반환하고 그렇지 않으면 사용자가 로그인하지 못하도록 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'오류가 있는 경우 적절한 오류 메세지를 표시한다.

 

마지막으로 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>
  );
}

 

 

 

15-6. 로그아웃 기능 추가하기

<SideNav />에 로그아웃 기능을 추가하기 위해 auth.ts로부터 signOut 함수를 호출해 <form> 요소에 전달한다.

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

 

 

💜 사용해보기

email : user@nextmail.com

password : 123456

 

 

 


16. 메타데이터 추가하기

 

16-1. 메타데이터란?

웹 개발에서 메타데이터는 웹 페이지에 대한 추가 세부정보를 제공한다.

페이지를 방문하는 사용자에게는 메타데이터가 표시되지 않는 대신 페이지의 HTML, 일반적으로 <head>요소에 포함되어 뒤에서 작동한다.

이 숨겨진 정보는 웹페이지의 콘텐츠를 더 잘 이해해야 하는 검색 엔진 및 기타 시스템에 매우 중요하다.

 

 

16-2. 메타데이터가 중요한 이유

메타데이터는 웹페이지의 SEO를 향상시키는 데 중요한 역할을 하여 검색 엔진과 소셜 미디어 플랫폼에서 웹페이지에 대한 접근성과 이해도를 높인다. 적절한 메타데이터는 검색 엔진이 웹페이지를 효과적으로 색인화하여 검색 결과에서 순위를 높이는 데 도움이 된다.

또한, Open Graph와 같은 메타데이터는 소셜 미디어에서 공유 링크의 모양을 개선하여 사용자에게 콘텐츠를 더욱 매력적이고 유익하게 만든다.

 

 

 

16-3. 메타데이터 유형

일반적인 메타데이터 유형을 알아보자.

 

💜 Title Metadata

브라우저 탭에 표시되는 웹페이지의 제목을 담당한다.

검색 엔진이 웹페이지의 내용을 이해하는 데 도움이 되므로 SEO에 매우 중요하다.

<title>Page Title</title>

 

 

💜 Description Metadata

웹페이지 콘텐츠에 대한 간략한 개요를 제공하며 검색 엔진 결과에 자주 표시된다.

<meta name="description" content="A brief description of the page content." />

 

 

💜 Keyword Metadata

웹페이지 콘텐츠와 관련된 키워드가 포함되어 있어 검색 엔진이 페이지를 색인화하는 데 도움이 된다.

<meta name="keywords" content="keyword1, keyword2, keyword3" />

 

 

💜 Open Graph Metadata

제목, 설명, 미리보기 이미지 등의 정보를 제공하여 소셜 미디어 플랫폼에서 공유할 때 웹페이지가 표시되는 방식을 향상시킨다.

<meta property="og:title" content="Title Here" />
<meta property="og:description" content="Description Here" />
<meta property="og:image" content="image_url_here" />

 

 

💜 Favicon Metadata

파비콘을 브라우저의 주소 표시줄이나 탭에 표시되는 웹페이지에 연결한다.

<link rel="icon" href="path/to/favicon.ico" />

 

 

 

16-4. 메타데이터 추가하기

Next.js에는 애플리케이션 메타데이터를 정의하는 데 사용할 수 있는 Metadata API가 있다.

애플리케이션에 메타데이터를 추가하는 방법은 두 가지가 있다.

  • Config-based : layout.js 또는 page.js 파일에서 정적 메타데이터 객체 또는 동적 generateMetadata 함수를 내보냄
  • File-based : Next.js에는 메타데이터 목적으로 특별히 사용되는 다양한 특수 파일이 있다.
    • favicon.ico, apple-icon.jpg, icon.jpg : 파비콘 및 아이콘에 활용됨
    • opengraph-image.jpg, twitter-image.jpg : 소셜 미디어 이미지를 위해 사용
    • robots.txt : 검색 엔진 크롤링에 대한 지침을 제공
    • sitemap.xml : 웹사이트의 구조에 대한 정보를 제공

정적 메타데이터에 이러한 파일을 유연하게 사용하거나 프로젝트 내에서 프로그래밍 방식으로 생성할 수 있다.

이 두 가지 옵션을 모두 사용하면 Next.js가 페이지에 대한 관련 <head>요소를 자동으로 생성한다.

 

 

💜 파비콘 및 오픈 그래프 이미지

/public 폴더에 두 개의 이미지 favicon.ico, opengraph-image.jpg가 있다.

이 이미지를 /app 폴더의 루트로 이동한다.

 

그 다음 Next.js는 이러한 파일을 자동으로 식별하여 파비콘 및 OG 이미지로 사용한다.

개발 도구에서 애플리케이션의 <head>요소에서 확인할 수 있다.

 

 

💜 페이지 제목 및 설명

layout.js 또는 page.js 파일의 메타데이터 객체를 포함하여 제목 및 설명과 같은 정보를 추가할 수 있다.

layout.js의 모든 메타데이터는 이를 사용하는 모든 페이지에서 상속된다.

 

루트 레이아웃에서 metadata 필드를 사용해 새 객체를 만든다.

// app/layout.tsx
import { Metadata } from 'next';
 
export const metadata: Metadata = {
  title: 'Acme Dashboard',
  description: 'The official Next.js Course Dashboard, built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};
 
export default function RootLayout() {
  // ...
}

 

Next.js는 애플리케이션에 제목과 메타데이터를 자동으로 추가한다.

하지만 특정 페이지에 대한 사용자 정의 제목을 추가하려면 해당 페이지 자체에 metadata 객체를 추가해야 한다.

중첩된 페이지의 메타데이터는 상위 페이지의 메타데이터보다 우선 적용된다.

 

모든 페이지에서 애플리케이션 제목을 반복하는 경우 title.template 필드를 사용해 페이지 제목에 대한 템플릿을 정의할 수 있다.

템플릿에는 페이지 제목과 포함하려는 기타 정보가 포함될 수 있다.

import { Metadata } from 'next';
 
export const metadata: Metadata = {
  title: {
    template: '%s | Acme Dashboard',
    default: 'Acme Dashboard',
  },
  description: 'The official Next.js Learn Dashboard built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};

%s는 특정 페이지 제목으로 대체된다.

/dashboard/invoices 페이지에 제목을 추가하자.

export const metadata: Metadata = {
  title: 'Invoices',
};

 

728x90