next-auth Credentials 로그인 (feat: callbacks 분석)

j_wisdom_h·2023년 12월 14일
5

Next.js 프로젝트

목록 보기
3/8

next.js 14ver, next-auth, mysql

next-auth공식문서
https://next-auth.js.org/

큰 흐름

로그인버튼클릭(username, password) (app/sigin/pages)
-> username, password을 인자로 signIn메소드 실행 (app/sigin/pages)
-> providers 내부 authorize함수 실행(app/api/auth/[...nextauth]/route.ts)
-> authorize함수 내부 await /api/login API실행(app/api/auth/[...nextauth]/route.ts)
-> DB에 username, password정보로 user가 있는지 확인 후 return (app/api/login/route.ts)
-> 리턴된 값이 authorize함수 내부 변수에 할당됨. 그 값을 authorize함수에서 return (app/api/auth/[...nextauth]/route.ts)
-> callbacks 메소드 실행(app/api/auth/[...nextauth]/route.ts)
-> 암호화되어 쿠키에 저장

1. next-auth 소개

1) next-auth란

공식문서에 전문적으로 설명되어있지만 내가 이해한 바는 아래와 같다.

NextAuth는 Next.js 앱을 만들 때 로그인과 보안을 도와주는 도구다. 여러 가지 로그인 방법을 지원하고, 데이터베이스를 쓸지 말지 선택할 수 있다. 그리고 사용자 데이터를 잘 보호하고, 필요한 기능을 쉽게 추가하거나 바꿀 수 있게 돕는다. 앱을 더 안전하게 만들어주고, 사용하기도 편리하다.

2) credentials

next-auth credentials 방식은 사용자명과 비밀번호를 이용한 인증 방식을 가리킨다. 이 방식은 사용자가 직접 자격 증명 정보를 제공하여 로그인하는 방식으로, 사용자의 로그인 정보를 직접 확인하여 인증하는 방법이다.

즉, 로그인 폼에 입력받은 정보로 인증하는 것!

2. providers (app/api/auth/[...nextauth]/route.ts)

1) next-auth 설치

npm install next-auth

2) next.js 14버전 next-auth 동적라우팅

나는 nextjs 14버전을 쓰고 있어서
이전버전에서 쓰는 pages/api/auth/[...nextauth].ts 가 아니라
app/api/auth/[...nextauth]/route.ts로 설정해주었다. 여기서 꽤나 헤맸다.😂

next.js에서 [...]은 동적 라우팅을 의미한다.

공식문서에 따르면

All requests to /api/auth/* (signIn, callback, signOut, etc.) will automatically be handled by NextAuth.js.

그러니까 /api/auth/ ~~ 로 들어오는 모든 api에 대해 /api/auth/[...nextauth]/route.ts가 자동으로 처리된다는 것이다.

3) 기본 포맷

가장 기본적인 포맷인 아래와 같다. provider와 callbacks를 채워보자.
참고로 providers로 여러개를 등록할 수 있다. (ex. kakao, google, naver)

import NextAuth, { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'

export const authOptions: NextAuthOptions = {
    // Configure one or more authentication providers
    providers: [
    	CredentialsProvider({
        	...
        })
    ], 
    callbacks: { ... }
}
const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }

(참고) 공식문서에서 예제를 보여주는데, 이것은 깃허브 oauth인증 코드다. credentials 방식에서는 clientId, clientSecret가 필요없다.

import NextAuth from "next-auth"
import GithubProvider from "next-auth/providers/github"
export const authOptions = {
  // Configure one or more authentication providers
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    // ...add more providers here
  ],
}
export default NextAuth(authOptions)

4) CredentialsProvider

먼저 공식 문서의 코드를 보자.

import CredentialsProvider from "next-auth/providers/credentials";
...
providers: [
  CredentialsProvider({
    // The name to display on the sign in form (e.g. "Sign in with...")
    name: "Credentials",
    // `credentials` is used to generate a form on the sign in page.
    // You can specify which fields should be submitted, by adding keys to the `credentials` object.
    // e.g. domain, username, password, 2FA token, etc.
    // You can pass any HTML attribute to the <input> tag through the object.
    credentials: {
      username: { label: "Username", type: "text", placeholder: "jsmith" },
      password: { label: "Password", type: "password" }
    },
    async authorize(credentials, req) {
      // Add logic here to look up the user from the credentials supplied
      const user = { id: "1", name: "J Smith", email: "jsmith@example.com" }

      if (user) {
        // Any object returned will be saved in `user` property of the JWT
        return user
      } else {
        // If you return null then an error will be displayed advising the user to check their details.
        return null

        // You can also Reject this callback with an Error thus the user will be sent to the error page with the error message as a query parameter
      }
    }
  })
]
...

[필수] authorize()를 정의해야 하며, 이는 HTTP POST를 통해 제출된 자격 증명을 입력으로 받아 사용자객제 or null or Error 중 하나를 반환해야한다.

Credentials 제공자의 authorize() 메서드는 두 번째 매개변수로 request 객체를 제공한다.

불필요한 코드를 정리하고 필요한 코드를 추가하면 아래와 같다.

import CredentialsProvider from "next-auth/providers/credentials";
...
providers: [
  CredentialsProvider({
    name: "Credentials",
    credentials: {
      username: {
        label: '이메일',
        type: 'text',
        placeholder: '이메일 주소 입력 요망',
      },
      password: { label: '비밀번호', type: 'password' },
    },
    async authorize(credentials, req): Promise<any> {
      try {
        const res = await fetch(
         `${process.env.NEXTAUTH_URL}/api/login`,
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              username: credentials?.username,
              password: credentials?.password,
            }),
         })
           
        const user = await res.json()
        return user || null
      } catch (e) {
        throw new Error(e.response)
      }
    }
  })
]

next-auth의 동작 흐름을 이해하는데 시간을 많이 썼다.

authhorize함수의 fetch는 어떤 동작을 하는 api인 것일까?
: DB에 해당 유저가 있는지 확인하는 하여 값을 반환

  • credentials 은 로그인폼에서 input으로 받은 데이터다.(이건 로그인폼 제출시에 데이터를 할당할 수 있다. 지금 코드에서는 해당 기능 코드가 없다.)
  • process.env.NEXTAUTH_URL 은 .env.local에 http://localhost:3000/로 환경변수로 두고 쓰고 있다.

credentials의 username, password을 body에 실어 post요청을 한다. 사실 이것만 보면 무슨 기능인지 모를 수 있다. 그러나 큰 흐름에서 보면 알 수 있다.

3. app/api/login/route.ts

그럼 이제 DB에 해당 유저가 있는지 확인하는 하여 값을 반환하는 api 코드를 보자.

나는 app/_lib/db에서 mysql을 연결할수 있는 코드를 작성했고, 쿼리를 실행할 수 있는 함수를 executeQuery를 만들어 두었다.

이 코드는 db종류, 설정방법에 따라 다양하게 쓸 수 있을 것 같다.

눈여겨 볼 부분은 인자로 request를 받았다는것. 이것이 [...nextauth]/route.ts의 authorize의 fetch()로 요청한 body다. 그리고 body의 username, password는 로그인폼에서 input으로 받은 데이터임을 꼭 유념하자.

import executeQuery from 'app/_lib/db'

export async function POST(request: Request) {
  const body = await request.json()
  // 이메일로 찾기
  const sql = `select email, password from bridge.user where email = '${body.username}'
  // db에서 쿼리 실행하기. 
  // executeQuery의 두번째 인자는 데이터인데, 새롭게 데이터를 추가(insert)할때 쓴다. 현재는 데이터를 찾는게(select) 목적이므로 빈배열
  const users: RowDataPacket[] = await executeQuery(sql, [])
  const user = users[0]

  // 유저가 없을때는 null을 반환한다. 유저가 있을때 password제외한 값 리턴
  if (user) {
	// userWithoutPass : username
    const { password, ...userWithoutPass } = user
    return new Response(JSON.stringify(userWithoutPass))
  } else return new Response(JSON.stringify(null))
}

여기서 Response로 리턴되어 app/api/auth/[...nextauth]/route.ts 의 const res = await fetch()에 할당된다.

 async authorize(credentials, req): Promise<any> {
      try {
   		// res에 반환 결과 할당
        const res = await fetch(
         `${process.env.NEXTAUTH_URL}/api/login`,
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              username: credentials?.username,
              password: credentials?.password,
            }),
         })
        
        const user = await res.json()
		// user가 있으면 user리턴 없으면 null리턴
        return user || null
      } catch (e) {
        throw new Error(e.response)
      }
    }

그럼 return user || null의 리턴은 어디로 가는 걸까?🤔

4. callbacks, pages (app/api/auth/[...nextauth]/route.ts)

성공적으로 성공한 로그인 결과를 어디서 확인할 수 있을까?
크롬 > 개발자도구 > 어플리케이션 > Storage > Cookies에서 next-auth.session-token에서 확인한다.

암호화가 되어있어 식별할 수 없다.

1) callbacks 종류

  • signIn(callback):
    사용자 로그인 시 실행되는 콜백으로, 사용자의 인증 정보를 검증하고 세션을 초기화할 수 있다.
  • signOut(callback):
    사용자 로그아웃 시 실행되는 콜백으로, 사용자 세션의 정리 작업을 수행하거나 로그아웃을 처리할 수 있다.
  • jwt(token, user, account, profile, isNewUser):
    JWT 토큰을 생성하거나 변경할 때 실행되는 콜백이다. 이를 사용하여 사용자의 토큰을 조작하거나 사용자 데이터를 추가로 저장할 수 있다.
  • session(session, user):
    사용자 세션이 생성될 때마다 실행되는 콜백으로, 세션 데이터를 수정하거나 사용자의 추가 정보를 세션에 저장할 수 있다.
  • redirect(url, baseUrl):
    페이지 리다이렉션을 사용자 정의하는 데 사용되는 콜백으로, 특정 URL로 사용자를 리다이렉트하거나 기본 URL을 수정할 수 있다.

    (공식문서)
    https://next-auth.js.org/configuration/callbacks#sign-in-callback

export const authOptions: NextAuthOptions ={
	providers : [...], 
    callbacks: {
       /* 
       *JWT Callback
       * 웹 토큰이 실행 혹은 업데이트될때마다 콜백이 실행
       * 반환된 값은 암호화되어 쿠키에 저장됨
       */
        async jwt({ token, user }) {
            if (user) {
                token.user = user
            }
            return token
        },
          
         /**
         * Session Callback
         * ClientSide에서 NextAuth에 세션을 체크할때마다 실행
         * 반환된 값은 useSession을 통해 ClientSide에서 사용할 수 있음
         * JWT 토큰의 정보를 Session에 유지 시킨다.
         */
        async session({ session, token, user }) {
            return session
        },
    },
      
    session: {
        strategy: 'jwt',
    },
    // 복호화
    secret: process.env.NEXTAUTH_SECRET,
      
    pages: {
        signIn: '/signin',
    }
 }
 ...

jwt함수 분석

callbacks: {
	async jwt({ token, user, account, profile, isNewUser }) {
      return token
    }
}

  • token: JWT 타입으로, JSON Web Token을 나타낸다.

  • user: User 또는 AdapterUser 타입 중 하나로, trigger가 "signIn" 또는 "signUp"일 때 name, email, 그리고 picture와 같은 정보가 포함된다. OAuth 제공자의 profile 콜백에서 반환되는 객체의 형태를 정의한다.

  • account: A 또는 null 타입으로, trigger가 "signIn" 또는 "signUp"일 때 사용된 인증 제공자에 대한 정보가 포함된다.

  • profile: P 타입으로, trigger가 "signIn"일 때 OAuth 제공자로부터 반환된 프로필 정보가 포함된다.
  • trigger: "signIn", "signUp", 또는 "update" 중 하나로, JWT 콜백이 왜 호출되었는지를 나타낸다.
  • isNewUser: trigger === "signUp" 대신 사용되는 불리언 플래그로, 사용자가 새로 생성되었는지를 나타낸다.

  • session: "jwt" 전략을 사용할 때, 클라이언트에서 useSession().update 메서드를 통해 전송된 데이터다.

user: 일반적으로 최종 사용자, 애플리케이션과 상호작용하는 사람으로 id, name, email 및 사용자 계정과 관련된 세부기타정보 포함

profile: 인증 공급자로부터 얻은 사용자 프로필정보. 제3 인증 공급자(ex.google)를 사용해 로그인하면 공급자는 사용자 ID, email, 이름 및 기타정보오 같은 세부정보가 포함된 사용자 프로필을 반환한다.

account : OAuth 또는 유사한 프로토클을 상요한느 인증 공급자를 처리할 때 사용자의 계정 정보를 나타낸다. OAuth 흐름에서 인증이 성공한 후 인증 공급자는 액세스 토큰, 새로 고침 토큰 및 기타 관련 정보가 포함된 "계정" 개체를 반환한다.

결론 : 애플리케이션의 사용자 모델과 관련된 정보에는 'user'를 사용하고, 인증 공급자로부터 얻은 세부 정보에는 'profile'을, 인증 프로세스(예: OAuth 토큰)와 관련된 세부 정보에는 'account'를 사용할 수 있다.

session분석

async session({ session, token }) {
  session.user = token as any;
  return session;
},

실행 순서
jwt -> session
'jwt' 콜백에서 생성되거나 수정된 ​​토큰은 일반적으로 클라이언트로 전송되어 저장됩니다. 구성에 따라 쿠키로 저장되거나 로컬 저장소에 저장될 수 있습니다.
session 콜백의 수정 사항을 포함한 세션 정보는 서버 측에서 관리됩니다. 세션 저장소나 데이터베이스에 저장될 수 있으며 세션 식별자(예: 세션 쿠키)가 클라이언트로 전송됩니다.

결론 : session에서는 jwt콜백으로 리턴된 토큰(jwt)를 이용해 session에 저장한다. 즉 session 콜백을 사용해 JWT정보를 기반으로 세션 객체를 맞춤설정 할 수 있다. 그리고 세션객체는 서버에 저장된다. 따라서 useSession도 서버에 있는 세션을 가져와 쓰는 것!

로그인 후, 쿠키

2) pages


커스텀 로그인 페이지를 추가하려면 pages 옵션을 사용한다.

    pages: {
        signIn: '/signin',
    }

이 코드는 NextAuth가 signIn Page로 '/signin' 이란 경로를 사용하라고 알려주는 것이다. 이렇게 되면 기본으로 제공하는 NextAuth 로그인 페이지가 아니라 본인이 직접 만든 로그인 페이지로 이동할 수 있다.

5. 커스텀 로그인 페이지(app/signin )

app/sigin/pages에 LoginForm을 import해서 쓰고 있다.
react-hook-form을 사용했다.

// app\_component\AuthForm.tsx
...

function LoginForm() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<IsignIn>({ resolver: yupResolver(logInschema) })

    const onSubmit = async (data: IsignUp) => {
        const { email, password } = data
  
        await signIn('credentials', {
            username: email,
            password: password,
            redirect: true,
            callbackUrl: '/',
        })
    }

    return (
        <div>
            <form
                onSubmit={handleSubmit(onSubmit)}
            >
              ...
              <button type="submit" className="orangeBtnL">
                    SignIn
              </button>
            </form>
        </div>
    )
}
...

LoginForm
SignIn 버튼을 클릭하면 onSubmit가 실행되고, 사용자로부터 입력을 받은email과 password를 추출한다. 그리고 이를 signIn 메서드를 통해 CredentialsProvider의 credentials으로 전달하여 로그인하는 과정이다. 로그인이 성공하면 callbackUrl로 지정된 경로로 사용자를 리디렉션한다.

//app\api\auth\[...nextauth]\route.ts
...
async authorize(credentials, req): Promise<any> {
      try {
        const res = await fetch(
         `${process.env.NEXTAUTH_URL}/api/login`,
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              username: credentials?.username,
              password: credentials?.password,
            }),
         })
           
        const user = await res.json()
        return user || null
      }
...

이제 body에 값을 들어가는 과정을 알게 되었다.

 //app/layout.tss
import '../styles/globals.css'

import Footer from '@/_component/Footer'
import Header from '@/_component/Header'
import Providers from '@/_component/Provider'

export default function RootLayout({
    children,
}: {
    children: React.ReactNode
}) {
    return (
        <html lang="en">
            <body className="relative">
                <Providers>
                    <Header></Header>
                    {children}
                </Providers>
                <Footer></Footer>
            </body>
        </html>
    )
}
// app\_component\Provider.tsx
'use client'

import { SessionProvider } from 'next-auth/react'

interface Props {
    children: React.ReactNode
}
function Providers({ children }: Props) {
    return <SessionProvider>{children}</SessionProvider>
}

export default Providers

세션을 최상위 루트에서 관리하는 이유는 Next.js 애플리케이션의 모든 페이지에서 일관된 세션 상태를 유지하기 위함이다.

//app\_component\Header.tsx
'use client'


import Link from 'next/link'
import { signOut, useSession } from 'next-auth/react'

const Header = () => {
    const { data: session } = useSession()

    return (
        <header>
            <div>
                <Link href="/"></Link>
            </div>
            <div>
                {session ? (
                    <button
                        onClick={() => signOut()}
                    >
                        Sign out
                    </button>
                ) : (
                    <>
                        <Link
                            href="/signup"             
                        >
                            Signup
                        </Link>
                        <Link href="/signin">
                            Signin
                        </Link>
                    </>
                )}
            </div>
        </header>
    )
}
export default Header
profile
뚜잇뚜잇 FE개발자

0개의 댓글