저번 시간에는 PlanetScale을 세팅하고 Next.js에서 Prisma와 연동을 통해 간단한 테이블 및 데이터를 생성해보고 테스트 해봤습니다. 이번 시간에는 본격적으로 NextAuth가 무엇이고 어떻게 사용하면 되는지 확인해본 다음에, 실습을 통해 구글 로그인 인증 처리를 진행 해보겠습니다.
공식 문서 : https://next-auth.js.org/getting-started/introduction
📚 NextAuth.js는 Next.js 애플리케이션을 위한 완벽한 오픈 소스 인증 솔루션입니다. 처음부터 Next.js와 Serverless를 지원하도록 설계되었습니다. - 공식 문서 참고
참고 : Email 로그인을 사용하려면 single-use verification tokens 을 저장하도록 데이터베이스를 구성해야 합니다.
Advanced options을 사용하면 로그인할 수 있는 계정 제어, JSON Web Tokens 인코딩 및 디코딩, 사용자 지정 쿠키 보안 정책 및 세션 속성을 설정하는 고유한 루틴을 정의하여, 로그인할 수 있는 사용자와 세션 재검증 빈도를 제어할 수 있습니다.
NextAuth를 실습 해본 결과, NextAuth를 잘 사용하기 위해 최소한 이 정도는 공식문서에서 먼저 읽어보면 좋을 거 같다라고 생각되는 글이 있었습니다. 이 정도는 알고 실습을 진행하면 아마 더 이해가 잘 될 겁니다. 혹은 반대로 무작정 따라해보고 다시 돌아와서 읽어보는 것도 나쁘지 않다고 생각됩니다.
참고 : https://next-auth.js.org/configuration/providers/oauth
NextAuth 문서를 보니 OAuth에 대한 정보가 있어서 한번 살펴보니 도움 될만한 내용인거 같아서 옮겨보도록 하겠습니다.
NextAuth.js의 인증 Providers는 사용자가 선호하는 기존 로그인(ex. 구글, 네이버, 카카오 등)으로 로그인할 수 있도록 하는 OAuth 정의입니다. 미리 정의된 여러 Providers를 사용하거나 사용자 정의 OAuth configuration을 작성할 수 있습니다. (대부분 다 정의가 되어있습니다)
OAuth 흐름은 일반적으로 6개의 부분으로 구성됩니다:
아래 그림을 통해 이해해보도록 하겠습니다.
✍️ NextAuth가 제공하는건 Providers에 대해 각각마다 URL은 기본적으로 정의해놓았기 때문에 개발자 입장에서는 그냥 .env 파일에 Client ID와 Client secret 만 등록하면 됩니다. 내부적으로 알아서 해줍니다. 그런 측면에 있어서 빠르게 인증을 도입할 수 있고, 어느 정도 검증 받았다고 볼 수 있기 때문에 직접 구현하는 것보다 안전할 수 있어서 좋은거 같습니다.
저는 prisma + planetscale 과 연동할 예정이므로 Database Adapter라는 내용을 읽어볼 필요가 있습니다.
💻 참고 : https://next-auth.js.org/configuration/databases
NextAuth.js는 여러 데이터베이스 어댑터를 제공합니다.
v4 이후 NextAuth.js는 더 이상 기본적으로 포함된 어댑터와 함께 제공되지 않습니다. 앞으로는 사용 가능한 여러 어댑터 중 하나를 직접 설치해야 합니다. 자세한 내용은 개별 어댑터 설명서 페이지를 참조하십시오. 즉, prisma를 NextAuth와 같이 사용하려면 PrismaAdapter라는 걸 설치해야 합니다.
💻 참고 : https://authjs.dev/reference/adapters
Auth.js / NextAuth.js 어댑터를 사용하면 모든 데이터베이스 서비스 또는 여러 다른 서비스에 동시에 연결할 수 있습니다.
Auth.js는 모든 데이터베이스에서 사용할 수 있습니다. Model은 Auth.js가 데이터베이스에서 기대하는 구조를 알려줍니다. Model은 사용하는 어댑터에 따라 약간 다르지만 일반적으로 다음과 같이 나타납니다:
사용자 모델은 사용자의 name 및 email 주소와 같은 정보를 위한 것입니다. email 주소는 선택사항이지만 사용자에 대해 지정된 email 주소는 고유(unique)해야 합니다.
NOTE. 사용자가 처음에 OAuth Provider에 로그인하면, OAuth Provider가 반환하는 경우 OAuth profile의 email을 사용하여 email 주소가 자동으로 채워집니다. 이렇게 하면 사용자에게 연락할 수 있는 방법이 제공되며, 사용자가 나중에 OAuth Provider와 로그인할 수 없는 경우에도 계정에 대한 액세스를 유지하고 email 주소를 사용하여 로그인할 수 있습니다.
데이터베이스의 User 생성은 자동으로 이루어지며, 사용자가 Provider와 처음 로그인할 때 수행됩니다. 첫 번째 로그인이 OAuth Provider를 통해 수행되는 경우 저장되는 기본 데이터는 id
, name
, email
, image
입니다. OAuth Provider의 profile()
callback에서 추가 필드를 반환하여 프로필 데이터를 추가할 수 있습니다.
Email Provider를 통해 첫 번째 로그인하는 경우, 저장된 사용자는 id, email, emailVerified을 가지게 됩니다. 여기서 emailVerified은 사용자가 생성된 시점의 timestamp입니다.
Account 모델은 User와 연결된 OAuth 계정에 대한 정보를 제공합니다.
한 명의 사용자가 여러 개의 계정을 가질 수 있지만 각 계정은 한 명의 사용자만 가질 수 있습니다. (ex. 기운찬곰이라는 사용자는 구글, 카카오, 네이버 등의 계정을 가질 수 있는 거랑 동일한 의미겠지요)
데이터베이스에 계정이 자동으로 생성되며 사용자가 Provider에 처음 로그인하거나 Adapter.linkAccount
메서드가 호출될 때 발생합니다. 저장되는 기본 데이터는 access_token, expires_at, refresh_token, id_token, token_type, scope, session_state 입니다. OAuth Provider의 account()
callback안에서 다른 필드를 저장하거나 필요하지 않은 필드를 제거하여 반환할 수 있습니다.
Accounts와 Users 연결은 계정이 동일한 email 주소를 가지고 있고 사용자가 현재 로그인한 경우에만 자동으로 수행됩니다.
Session 모델은 데이터베이스 세션에 사용됩니다. JSON Web Tokens이 사용 가능한 경우에는 사용되지 않습니다. 데이터베이스를 사용하여 Users 및 Accounts을 유지하고 세션에 JWT를 사용할 수 있습니다. session.strategy 옵션을 참조하십시오.
단일 User는 여러 세션을 가질 수 있으며, 각 세션은 한 사용자만 가질 수 있습니다.
Tip. 세션을 읽을 때 expires 필드가 잘못된 세션을 나타내는지 확인하고 데이터베이스에서 삭제합니다. 또한 활성 세션 검색 중 데이터베이스에 대한 추가 삭제 호출을 방지하기 위해 백그라운드에서 정기적으로 이 정리를 수행할 수 있습니다. 이로 인해 일부 경우 성능이 약간 향상될 수 있습니다.
Verification Token 모델은 암호 없는 로그인을 위한 토큰을 저장하는 데 사용됩니다.
한 사용자가 여러 개의 Verification Tokens을 열 수 있습니다(예: 다른 장치에 로그인하는 경우).
향후 다른 검증 목적(예: 2FA/magic codes 등)을 위해 확장 가능하도록 설계되었습니다.
참고 : https://next-auth.js.org/configuration/callbacks
NextAuth.js를 사용하면 built-in 콜백을 통해 인증 흐름의 다양한 부분에 연결할 수 있습니다. 콜백은 action이 수행될 때 발생하는 작업을 제어하는 데 사용할 수 있는 비동기 함수입니다.
Tip. JWT을 사용할 때 Access Token 또는 User ID와 같은 데이터를 브라우저에 전달하려면, jwt callback이 호출될 때 토큰의 데이터를 유지한 다음, 세션 콜백에서 브라우저로 데이터를 전달할 수 있습니다.
아래 콜백에 대한 handler를 지정할 수 있습니다.
...
callbacks: {
async signIn({ user, account, profile, email, credentials }) {
return true
},
async redirect({ url, baseUrl }) {
return baseUrl
},
async session({ session, user, token }) {
return session
},
async jwt({ token, user, account, profile, isNewUser }) {
return token
}
...
}
signIn()
콜백을 사용하여 사용자가 로그인할 수 있는지 여부를 제어합니다. 예를 들어, 사용자가 휴면 계정이거나 블랙 리스트 계정인 경우를 확인해서 적절한 조치를 할 수 있을 것입니다.
...
callbacks: {
async signIn({ user, account, profile, email, credentials }) {
const isAllowedToSignIn = true
if (isAllowedToSignIn) {
return true
} else {
// Return false to display a default error message
return false
// Or you can return a URL to redirect to:
// return '/unauthorized'
}
}
}
...
예를 들어 구글 로그인이 성공적으로 완료된 경우, user에는 사용자가 정보 제공에 동의한 항목인 간단한 프로필 정보가 넘어올 것이고, account에는 provider, access_token, expires_at, refresh_token... 등의 정보가 넘어올 것입니다.
이 콜백은 JWT가 생성될 때(즉, sing-in 시) 또는 업데이트될 때(즉, 클라이언트에서 세션에 액세스할 때마다)마다 호출됩니다. 반환된 값은 암호화되어 쿠키에 저장됩니다.
/api/auth/signin
, /api/auth/session
에 대한 요청 및 getSession()
, getServerSession()
, useSession()
에 대한 호출은 JWT 세션을 사용하는 경우에만 이 함수를 호출합니다. 이 메서드는 데이터베이스에서 세션을 유지할 때 호출되지 않습니다.
컨텐츠 user, account, profile, isNewUser는 Provider와 데이터베이스 사용 여부에 따라 달라집니다. User Id, OAuth Access Token과 같은 데이터를 유지할 수 있습니다. 아래 예시로 access_token
과 user.id
를 참조하십시오. 클라이언트 측에서 이를 노출하려면 session 콜백도 확인하십시오.
...
callbacks: {
async jwt({ token, account, profile }) {
// Persist the OAuth access_token and or the user id to the token right after signin
if (account) {
token.accessToken = account.access_token
token.id = profile.id
}
return token
}
}
...
Tip. if 분기를 사용하여 (토큰 이외의) 매개 변수가 있는지 확인합니다. 이들이 존재할 경우 콜백이 처음으로 호출된다는 것을 의미합니다(즉, 사용자가 로그인하고 있음). 이것은 JWT의 access_token과 같은 추가 데이터를 유지하기에 좋은 장소입니다. 이후 호출에는 token 파라미터만 포함됩니다. (오호. 그런것이군요)
세션 콜백은 세션이 확인될 때마다 호출됩니다. 기본적으로 토큰의 하위 집합만 보안을 강화하기 위해 반환됩니다. jwt()
콜백을 통해 토큰에 추가한 것(위의 access_token, user.id)을 사용할 수 있도록 하려면 클라이언트가 사용할 수 있도록 여기에 명시적으로 전달해야 합니다.
e.g. getSession()
, useSession()
, /api/auth/session
...
callbacks: {
async session({ session, token, user }) {
// Send properties to the client, like an access_token and user id from a provider.
session.accessToken = token.accessToken
session.user.id = token.id
return session
}
}
...
Tip. JWT을 사용할 때 session() 콜백 전에 jwt() 콜백이 호출되므로, JWT에 추가한 모든 항목(provider로부터 전달받은 access_token 또는 id와 같이…)이 session 콜백에서 즉시 사용할 수 있습니다.
그니까 jwt() 콜백 이후 session() 콜백이 호출되며, 따라서 클라이언트에서 세션에 접근할 때 부족한 정보가 있으면 jwt 토큰에다가 추가해주고, 이걸 다시 session 콜백에서 꺼내서 전달하라는 거네요. 이제야 이해가 됩니다. 이 순서와 로직이 좀 헷갈렸거든요... ㅠㅠ
pnpm add next-auth
pnpm add @prisma/client @next-auth/prisma-adapter
pnpm add prisma --save-dev
참고 : https://github.com/nextauthjs/next-auth/issues/6106#issuecomment-1597299312
근데 이게 보니까 @auth/prisma-adapter가 있고, @next-auth/prisma-adapter가 있는거 같습니다. 공식 문서에 나와있는건 전자이지만 NextAuthOptions 라는 타입 적용시 타입스크립트 이슈가 있습니다. 사용 빈도는 후자가 더 높은거 같고요. 업데이트 일자는 전자가 더 빠른거 같고... 뭘 사용하라는 걸까요? ㅠㅠ
저는 그래서 타입 이슈가 없는, 다운로드 수가 더 많은 @next-auth/prisma-adapter 를 사용하기로 결정했습니다.
기본적으로 Database URL과 구글 Client ID와 Secet을 적어줍니다. client ID와 secret 만드는 과정은 생략하겠습니다.
// .env
DATABASE_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
일단 기본적으로 Next.js App Router를 통해 [...nextauth]를 아래와 같이 정의해주겠습니다.
// /app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth/next";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
NextAuthOption는 따로 분리하여 작성해주겠습니다. 여기서는 간단하게 PrismaAdapter를 통해 prisma + planetscale과 연동해주었고, providers는 GoogleProvider만 작성해주었습니다. 네이버나 카카오 로그인을 추가하고 싶다면 그러한 Provider를 찾아서 넣어주면 됩니다.
// /lib/auth.ts
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import prisma from "@/lib/db";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
};
User와 Account 정보를 저장할 것이므로 두 테이블만 있으면 되지 않을까 싶은데.. 일단 실험삼아 Session도 넣어보도록 하겠습니다.
NextAuth Prisma schema 참고 : https://authjs.dev/reference/adapter/prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
NextAuth 문서에서는 prisma migrate
하라고 되어있지만, planetscale은 db push
를 사용하라고 했으므로 db push를 해주겠습니다. 그러면 제대로 생성된 것을 알 수 있습니다.
npx prisma db push
브라우저에서 구글 로그인을 하기 위해서 next-auth에서 제공해주는 signIn 메서드를 사용하면 됩니다. singIn('google');
이라고 사용하면 구글 로그인을 하겠다는 뜻입니다.
// src/components/UserAuthForm.tsx
"use client";
import { cn } from "@/lib/utils";
import { Button } from "./ui/Button";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { Icons } from "./Icons";
import { toast } from "@/hooks/use-toast";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
export default function UserAuthForm({
className,
...props
}: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false);
const loginWithGoogle = async () => {
setIsLoading(true);
try {
await signIn("google");
} catch (error) {
// toast notification
toast({
title: "There was a problem",
description: "There was an error logging in with Google.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<div className={cn("flex justify-center", className)} {...props}>
<Button
size="sm"
className="w-full"
onClick={loginWithGoogle}
isLoading={isLoading}
>
{!isLoading && <Icons.google className="w-4 h-4 mr-2" />}
Google
</Button>
</div>
);
}
이제 로그인을 시도해보면 다음과 같이 뜨는 것을 알 수 있습니다. 많이 보던 화면이죠?
사용자가 승인을 한다면 내부적으로 많은 일(OAuth 참고)이 일어난 후, DB에 데이터가 저장될 것입니다. prisma studio를 통해 데이터가 제대로 들어갔는지 확인해보겠습니다.
User 테이블입니다. id, name, email image 등 사용자 프로필 기본 정보가 제대로 들어간 것을 알 수 있군요.
다음은 Account 테이블입니다. OAuth 계정에 대한 정보가 들어가 있는 것을 알 수 있습니다. 보면 User id와 Account id가 서로 매핑되는 있습니다. 아무래도 1대 다 관계를 표현한 것이겠죠.
다음은 Session 테이블입니다. 데이터베이스 세션에 사용 된다고 합니다. 지금은 기본적으로 session.strategy가 "database"라고 저장이 된 거 같습니다. JWT를 사용한다면 아마 사용할 필요는 없을 거 같습니다.
참고 : https://next-auth.js.org/configuration/options#session
session.strategoy: “database” 세션-쿠키(혹은 jwt) 방식 대신에 jwt 방식으로 바꿔보겠습니다.
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: {
strategy: "jwt",
},
};
그리고 나서 db table을 초기화 해준다음에, 로그인을 다시 해보면 짜잔. Session 테이블에는 아무것도 저장되지 않았습니다.
브라우저 쿠키를 보면 jwt 토큰이 아마 저장되어있는거 같네요. next-auth.session-token이 jwt일것으로 보여집니다. 이것을 복호화해보면 토큰 페이로드 정보가 있을거 같은데… 아무튼 그리고 csrf 토큰도 있네요.
쿠키 및 JWT를 암호화/복호화하고 email verification tokens을 해시하는데 사용합니다. 이 값은 NextAuth 및 Middleware의 secret 옵션에 대한 기본값입니다. 이것을 생성하지 않으면 아래와 같은 경고가 나올 것입니다. 운영에서는 반드시 필요하기 때문에 경고가 아닌 에러가 발생하게 됩니다.
[next-auth][warn][NO_SECRET]
https://next-auth.js.org/warnings#no_secret
참고 : https://next-auth.js.org/configuration/options#secret
생성하는 방법은 다음과 같습니다. openssl을 사용하는게 간단한 랜덤 암호를 생성해주었습니다. .env 파일에 추가하고 나면 경고는 사라지게 됩니다.
$ openssl rand -base64 32
// .env
NEXTAUTH_SECRET=
기본적인 session 정보를 확인해보면 name, email, image 를 확인해볼 수 있습니다. 공식문서에서도 그러한 내용이 있습니다.
참고 : https://next-auth.js.org/getting-started/client
{
"user": {
"name": "김찬수 (기운찬곰)",
"email": "ckstn0777@gmail.com",
"image": "https://lh3.googleusercontent.com/a/AGNmyxaz07fUjVpm2djSvvZZC-80tvJe4yuBgaGUqPcg=s96-c"
},
"expires": "2023-07-18T12:38:25.167Z"
}
여기에는 프레젠테이션 목적으로 로그인한 사용자에 대한 정보(예: 이름, 이메일, 이미지)를 페이지에 표시하는 데 필요한 충분한 데이터가 포함된 최소 페이로드가 포함되어 있는 것입니다.
만약, session에 필요한 데이터가 없어서 직접 추가해주고 싶다면 어떻게 해야 할까요? session 객체에서 추가 데이터를 반환해야 하는 경우 세션 콜백을 사용하여 클라이언트에 반환되는 세션 객체를 사용자 정의할 수 있습니다.
예를 들어, session 객체에 id 값을 추가해본다고 합시다. 그렇다면 다음과 같이 할 수 있습니다.
callbacks: {
// session 접근 시 호출 된다. jwt 콜백 이후 호출 된다.
async session({ session, user, token }) {
session.user.id = token.id;
return session;
},
// jwt 콜백은 jwt 토큰 생성 시 또는 업데이트 시 호출 된다.
// 즉, 로그인 시에도 호출되며 session 콜백 전에도 호출 된다.
async jwt({ token, user }) {
// 초기 로그인 시에는 user 정보가 있음
if (user) {
token.id = user.id;
}
return token;
},
},
jwt 콜백에서 토큰에 id 정보를 추가해줘야 하며, 이후 session 콜백에서는 토큰에 저장된 id 정보를 가져와서 세션에 추가한 다음 넘겨주면 됩니다.
참고 : https://next-auth.js.org/getting-started/typescript
단, 그냥 하면 타입스크립트 에러가 발생하는데, 타입스크립트 문서에 해결 방법이 존재하니 참고 바랍니다. 저는 아래와 같이 해결했습니다.
// src/types/next-auth.d.ts
import NextAuth, { DefaultSession } from "next-auth";
type UserId = string;
declare module "next-auth/jwt" {
interface JWT {
id: UserId;
}
}
declare module "next-auth" {
interface Session {
user: {
id: UserId;
} & DefaultSession["user"];
}
}
Next.js API Route 혹은 서버 컴포넌트 내에서 세션 접근 방법은 getServerSession 메서드를 사용하면 됩니다.
// src/lib/auth.ts
export const authOptions: NextAuthOptions = {
...
}
export const getAuthSession = () => getServerSession(authOptions);
서버 컴포넌트 내에서 getServerSession 를 사용했습니다. 따라서 브라우저 콘솔이 아닌 터미널에서 해당 정보가 출력되는 것을 알 수 있습니다.
// src/components/Navbar.tsx
export default async function Navbar() {
const session = await getAuthSession();
console.log(session);
...
}
참고 : https://next-auth.js.org/getting-started/client
만약 클라이언트 컴포넌트에서 세션에 접근해야 한다면 SessionProvider를 사용해서 session state를 공유하는 설정을 해야하고, useSession 리액트 hook을 통해 사용 가능합니다.
이번 시간에는 NextAuth에 대해 보다 자세히 알아보게 된 거 같습니다. 생각보다 사용하기 위해 알아야 할 게 많은거 같기도 하네요. 그래도 이 정도면 정말 편한거 같다고 생각합니다. 직접 구현할 생각을 하면... 흠... 한번쯤 해보는 것도... 분명 많은 도움이 될 것 입니다.
아무튼 인증 처리는 프로젝트로 따지면 거의 절반 정도에 해당하는 난이도 있는 작업이라 생각합니다. 나머지 기능 구현은 그나마 쉬운 편이죠. 제 개인적인 생각입니다. ㅎㅎ
잘봤습니다. 좋은 글 감사합니다.