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)
-> 암호화되어 쿠키에 저장
공식문서에 전문적으로 설명되어있지만 내가 이해한 바는 아래와 같다.
NextAuth는 Next.js 앱을 만들 때 로그인과 보안을 도와주는 도구다. 여러 가지 로그인 방법을 지원하고, 데이터베이스를 쓸지 말지 선택할 수 있다. 그리고 사용자 데이터를 잘 보호하고, 필요한 기능을 쉽게 추가하거나 바꿀 수 있게 돕는다. 앱을 더 안전하게 만들어주고, 사용하기도 편리하다.
next-auth credentials 방식은 사용자명과 비밀번호를 이용한 인증 방식을 가리킨다. 이 방식은 사용자가 직접 자격 증명 정보를 제공하여 로그인하는 방식으로, 사용자의 로그인 정보를 직접 확인하여 인증하는 방법이다.
즉, 로그인 폼에 입력받은 정보로 인증하는 것!
npm install 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가 자동으로 처리된다는 것이다.
가장 기본적인 포맷인 아래와 같다. 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)
먼저 공식 문서의 코드를 보자.
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의 username, password을 body에 실어 post요청을 한다. 사실 이것만 보면 무슨 기능인지 모를 수 있다. 그러나 큰 흐름에서 보면 알 수 있다.
그럼 이제 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
의 리턴은 어디로 가는 걸까?🤔
성공적으로 성공한 로그인 결과를 어디서 확인할 수 있을까?
크롬 > 개발자도구 > 어플리케이션 > Storage > Cookies에서 next-auth.session-token에서 확인한다.
암호화가 되어있어 식별할 수 없다.
(공식문서)
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',
}
}
...
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 콜백에서 반환되는 객체의 형태를 정의한다.
isNewUser: trigger === "signUp" 대신 사용되는 불리언 플래그로, 사용자가 새로 생성되었는지를 나타낸다.
session: "jwt" 전략을 사용할 때, 클라이언트에서 useSession().update 메서드를 통해 전송된 데이터다.
user: 일반적으로 최종 사용자, 애플리케이션과 상호작용하는 사람으로 id, name, email 및 사용자 계정과 관련된 세부기타정보 포함
profile: 인증 공급자로부터 얻은 사용자 프로필정보. 제3 인증 공급자(ex.google)를 사용해 로그인하면 공급자는 사용자 ID, email, 이름 및 기타정보오 같은 세부정보가 포함된 사용자 프로필을 반환한다.
account : OAuth 또는 유사한 프로토클을 상요한느 인증 공급자를 처리할 때 사용자의 계정 정보를 나타낸다. OAuth 흐름에서 인증이 성공한 후 인증 공급자는 액세스 토큰, 새로 고침 토큰 및 기타 관련 정보가 포함된 "계정" 개체를 반환한다.
결론 : 애플리케이션의 사용자 모델과 관련된 정보에는 'user'를 사용하고, 인증 공급자로부터 얻은 세부 정보에는 'profile'을, 인증 프로세스(예: OAuth 토큰)와 관련된 세부 정보에는 'account'를 사용할 수 있다.
async session({ session, token }) {
session.user = token as any;
return session;
},
실행 순서
jwt -> session
'jwt' 콜백에서 생성되거나 수정된 토큰은 일반적으로 클라이언트로 전송되어 저장됩니다. 구성에 따라 쿠키로 저장되거나 로컬 저장소에 저장될 수 있습니다.
session 콜백의 수정 사항을 포함한 세션 정보는 서버 측에서 관리됩니다. 세션 저장소나 데이터베이스에 저장될 수 있으며 세션 식별자(예: 세션 쿠키)가 클라이언트로 전송됩니다.
결론 : session에서는 jwt콜백으로 리턴된 토큰(jwt)를 이용해 session에 저장한다. 즉 session 콜백을 사용해 JWT정보를 기반으로 세션 객체를 맞춤설정 할 수 있다. 그리고 세션객체는 서버에 저장된다. 따라서 useSession도 서버에 있는 세션을 가져와 쓰는 것!
커스텀 로그인 페이지를 추가하려면 pages 옵션을 사용한다.
pages: {
signIn: '/signin',
}
이 코드는 NextAuth가 signIn Page로 '/signin' 이란 경로를 사용하라고 알려주는 것이다. 이렇게 되면 기본으로 제공하는 NextAuth 로그인 페이지가 아니라 본인이 직접 만든 로그인 페이지로 이동할 수 있다.
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