즉 인증은 누구인지 신원을 체크하는 것, 허가는 해당 앱에서 어떤일을 할 수 있는지 결정하는 것을 의미한다.
NextAuth.js는 세션 관리, 로그인 로그아웃 등 인증의 여러 복잡한 면들을 추상화하여 간단하고 쉽게 구현할 수 있게 해주는 라이브러리이다.
npm install next-auth@beta
Next.js 14 버전과 함께 사용하기 위해서는 beta
버전을 설치해주어야한다.
openssl rand -base64 32
터미널에서 opennssl을 통해 암호키를 생성해주고 .env 파일에 AUTH_SECRET=my-secret-key
로 추가해주자. 해당 암호키를 통해 쿠키를 암호화하고, 유저 세션의 보안에 이용할 것이다.
또한 배포에서 인증을 작동시키기 위해 Vercel에서도 환경변수를 업데이트 해주어야한다. 업데이트하는 법은 Vercel의 공식문서를 참조하자.
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
};
프로젝트의 최상단에 auth.config.ts
파일을 생성해주자. 여기서 export 하는 authConfig
객체는 NextAuth.js를 위한 설정 옵션들을 포함하고 있다. 현재는 pages
옵션만 갖고있다.
pages
옵션을 통해 커스텀 로그인, 로그아웃, 에러 페이지들을 위한 라우트를 명시할 수 있다. 이것은 필수 옵션은 아니지만 pages
옵션에 signIn: '/login'
을 추가해주는것으로 유저는 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; // 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;
authorized
콜백은 요청이 Next.js 미들웨어를 통한 접근이 허가되었는지 확인하기 위해 사용되었다. 해당 콜백은 요청이 완료되기 전에 호출되고, auth
와 request
프로퍼티가 존재하는 객체를 전달받는다. auth
프로퍼티는 유저의 세션을 포함하고 있고, request
프로퍼티는 들어오는 요청이 포함되어있다.
provider
옵션은 다른 로그인 옵션들을 리스트하는 배열이다. 현재는 비어있지만 나중에 더 살펴보자.
다음으로, 이제 이 authConfig
객체를 미들웨어 파일에서 import 해보자. 프로젝트의 루트에서 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
프로퍼티를 export하고 있다. 미들웨어의 matcher
옵션을 통해 미들웨어가 수행될 path를 명시할 수 있다.
DB에 패스워드를 저장하기 전에 해싱하는 것은 좋은 관습이다. 해싱은 패스워드를 랜덤해보이는 고정길이의 문자열로 변환해주며, 유저의 데이터가 노출되어도 한층 더 안전하게 해준다.
이전에 사용했던 bcrypt
패키지를 다시 사용하여 해싱을 처리할 것이다. 하지만 bcrypt
패키지는 Next.js 미들웨어에서 사용 불가능한 Node.js API에 의존하기 때문에 파일을 분리해주어야한다.
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
});
authConfig
객체를 spread하는 auth.ts
파일을 생성해주자.
provider
옵션은 상기한대로 구글, 깃허브와 같은 다른 로그인 옵션들을 리스트해두는 배열이다. 여기서는 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({})],
});
알면 좋은 사실 : 여기서는 Credentials provider만 사용하지만, 일반적으로는 OAuth나 email provider들을 통한 로그인 대안 방법들을 함께 사용하는 것을 추천한다.
authorize
함수를 통해 인증 로직을 핸들링 할 수 있다. Server Actions 처럼, zod
를 이용해 이메일과 패스워드가 DB에 존재하는지 확인하기 전에 먼저 검증할 수 있다.
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);
},
}),
],
});
검증 후, DB에 해당 유저가 존재하는지 쿼리를 보내는 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
을 리턴받을 것이다.
이제 인증 로직을 로그인 form과 연결해주어야한다. action.ts
파일에서, authenticate
라는 새로운 액션을 만들어주자. 이 액션은 auth.ts
에서 signIn
함수를 import 해와야만 한다.
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;
}
}
마지막으로, login-form.tsx
컴포넌트에서, React의 useFormState
를 사용해 Server Action을 호출하고 form 에러를 핸들링하고, useFormStatus
를 통해 form의 pending state를 핸들링하자.
'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 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 />
에 추가하기 위해, signOut
함수를 auth.ts
파일에서 import 해와 <form>
의 action에서 호출해주자.
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>
);
}
오 쭉 읽어봤는데 진짜 흥미롭네요 나중에 취업 하고 개인 프로젝트로 써먹어봐야겠다
동민님 최고 ㅎ
아 나도 블로그 써야하는데 왜케 귀찮지