이번 포스팅에서는 next.js 로 개인프로젝트를 진행하며 next-auth를 적용하여 로그인 기능을 구현했던 과정에 대해 공유해보려 한다.
왜 Next-Auth 였나 ?
무엇보다 Next Auth 를 사용한 가장 큰 이유는 쉽고 간단하게 구현할 수 있기 때문이였다. DB 통신을 하여 로그인을 직접 구현하는 것 이외에도 Oauth, Email 를 사용하여서도 쉽게 구현할 수 있도록 가이드가 잘 되어있고 발급받은 jwt 를 crsf 토큰을 활용해 매 사용자 요청을 검증하도록 안전하게 설계되어 있다.(자세한 정보는 next-auth 공식 레퍼런스(https://next-auth.js.org)를 참고)
Oauth 소셜로그인 기능도 구현해보고 싶었지만 일단 제일 기본적인 로그인 부터 구현해보고 싶어서 이번 포스팅에서는 next-auth 에서 제공하는 CredentialsProvider를 사용하여 아이디와 비밀번호를 입력받아 DB 계정정보 일치여부로 로그인 기능을 구현하는 과정에 대해 정리해보려한다.
목차
- Next-Auth 설치
- CredentialsProvider를 사용하여 로그인 기능 구현
- middleware에서 로그인 여부로 접근제한 기능 구현
1. Next-Auth 설치
npm i next-auth // next-auth 설치
우선 프로젝트 경로에서 위 명령어를 실행시켜서 next-auth를 설치한다.
next-auth의 설치가 완료되고 나면 아래와 같이 pages/api/auth 폴더 아래에 [...nextauth].tsx 파일이 생성된다.
2. CredentialsProvider를 사용하여 로그인 기능 구현
우선 next-auth SECRET 환경변수부터 먼저 설정을 해줘야 한다. SECRET 속성을 지정해주지 않으면 콘솔에 NO_SECRET 경고 문구가 계속 표출이 되는 것을 확인할 수 있다.
SessionProvider 설정
next auth 설정 이전에 로그인후 세션을 전역에서 사용하기 위해 app.tsx에서 SessionProvider로 전체를 감싸주자.
_app.tsx
import type { AppProps, AppContext } from 'next/app';
import { SessionProvider} from 'next-auth/react';
import Head from 'next/head';
const App = ({ Component, pageProps }: AppProps) => {
return (
<SessionProvider>
<div className='main_area'>
<Head>
</Head>
<Component {...pageProps} />
</div>
</SessionProvider>
);
};
export default App;
.env
#NEXT_AUTH
SECRET = adsfasdf!@#DFSDF1sdfsdfsdf
NEXTAUTH_URL = http://localhost:3000
//터미널에서 실행
openssl rand -base64 32
SECRET 변수 값은 아무거나 지정해주면 주는데 터미널에서 위 명령어를 실행해서 .env 파일에 SECRET 변수에 넣어주고 NEXTAUTH_URL은 개발서버 URL 을 적어주었다.(위 SECRET 은 임의로 만든 예시)
이렇게 환경변수를 만들어주고 아래 [...nextauth].tsx 에서 SECRET 프로퍼티에서 사용한다.
pages/api/auth/[...nextauth].tsx
CredentilasProvider
[...nextauth].tsx 에서 인증방식을 설정할 수 있는데 CredentialsProvider를 사용하여 DB 계정 정보와 사용자가 입력한 정보의 일치여부를 확인한다.
CreedentialsProvider를 확인해보면 credentials 프로퍼티에 id와 password의 input 속성이 지정되어 있는데 이 속성은 next-auth 에서 기본적으로 제공해주는 로그인 화면(/api/auth/signin) 화면의 input 속성에 적용된다. 로그인 화면을 별도로 만들어서 사용할 것이기에 신경쓰지 않아도 된다.
authorize 함수에서 로그인화면에서 사용자가 입력한 id값과 password 값을 전달 받아 일치여부를 확인하고 로그인 사용자 정보를 넘겨 주게된다. 사용자가 입력한 값은 첫번째 인자인 credentials에 담겨져 있다.
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { verifyPassword } from '@/utils/Bcypt';
export default NextAuth({
providers: [
CredentialsProvider({
name: 'ID, PW', // 로그인 방식명
credentials: {
id: { label: '아이디', type: 'text', placeholder: '아이디를 입력하세요.' },
password: { label: '비밀번호', type: 'password' },
},
async authorize(credentials, req) {
const id = credentials?.id; // 사용자 입력 id 값
const password = credentials?.password; // 사용자 입력 pw 값
// DB에서 사용자가 입력한 id 값과 일치하는 사용자의 정보를 가져옴
const params = { type: 'getUser', id: id };
const user = await handleMySql(params).then((res) => {
return res.items[0];
});
//암호화된 pw를 복호화하여 사용자 입력 값과 일치하는 여부를 확인
const isValid = await verifyPassword(password!, user.USER_PASSWORD);
// 인증된 사용자의 경우 부가적인 사용자 정보와 함께 return
if (user && isValid) {
return { id: user.USER_ID, email: user.USER_EMAIL, name: user.USER_NICKNAME, image: user.USER_THMB_IMG_URL ? user.USER_THMB_IMG_URL : '', blogName: user.USER_BLOG_NAME };
} else {
// 인증되지 않은 사용자의 경우 null을 return
return null;
}
},
}),
],
secret: process.env.SECRET, //.env 파일에서 만든 secret 환경변수 사용
}
callbacks
CredentialsProvider에서 인증이 완료되고 사용자 정보를 return하면 callbacks 에서 부가적인 사용자 데이터를 위와 같이 session.user에 담아서 return 하면 로그인 후 session 에서 사용 가능하다.
로그인이나 로그아웃시 redirect 여부가 true로 넘어오면 같이 전달된 callbackUrl로 redirect됨. callbackUrl이 넘어오지 않으면 .env 파일에 정의된 NEXTAUTH_URL 로 redirect됨.
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { handleMySql } from '@/pages/api/HandleUser';
import { verifyPassword } from '@/utils/Bcypt';
export default NextAuth({
providers: [
... 중략 ...
],
secret: process.env.SECRET,
callbacks: {
async jwt({ token, user, trigger, session }: any) {
if (user) {
return {token, user}
}
return token;
},
async session({ session, token }: { session: any; token: any; user: any }) {
session.user.id = user.id;
session.user.name = user.name;
session.user.email = user.email;
session.user.image = user.picture;
session.user.blogName = user.blogName;
session.user.tokenExp = token.exp;
return session; // The return type will match the one returned in `useSession()`
},
async redirect({ url }) {
// 해당함수는 파라미터로 넘어온 redirect 여부가 true 여야 실행
// 파라미터 url은 callbackUrl 값이 담겨져 있음.
return url;
},
},
});
타입스크립트를 사용하는 경우 아래와 같이 auth.d.ts 파일을 만들어서 session 타입을 확장해야한다.
auth.d.ts
import NextAuth, { DefaultSession, User } from 'next-auth';
// user 객체에 id와 블로그 이름 추가
declare module 'next-auth' {
interface Session extends DefaultSession {
user?: {
id?: string;
blogName?: string;
tokenExp?: string;
} & DefaultSession['user'];
}
}
sessions
sessions 프로퍼티에서는 세션을 어떻게 관리할지, 만료시간 어떻게 할지 아래와 같이 설정할 수 있다.
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { handleMySql } from '@/pages/api/HandleUser';
import { verifyPassword } from '@/utils/Bcypt';
export default NextAuth({
providers: [
... 중략 ...
],
secret: process.env.SECRET,
callbacks: {
... 중략 ...
},
session: {
strategy: 'jwt', // jwt로 세션을 관리(쿠키에 저장됨)
maxAge: 60 * 60, // 세션 만료시간은 1 hour
},
});
maxAge 단위는 초 단위이다.
pages/Login.tsx
import React, { useState} from 'react';
import loginStyle from '../styles/Login.module.css';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/router';
const Login = () => {
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const login = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
//signIn 함수를 사용하여 [...nextauth].tsx의 authorize 함수로 사용자 입력 값을 전달
const result = await signIn('credentials', {
redirect: false,
id: id.replaceAll(' ', ''),
password: password
}).then((res) => {
if (!res?.error) {
router.back(); // 로그인 성공시 이전화면으로 이동
} else {
document.querySelector('.loginErrMsg')!.innerHTML = '<div class="mt5">아이디 또는 비밀번호를 잘못입력하였습니다.<br/>입력하신 내용을 다시 확인해주세요.</div>';
}
});
};
return (
<div className={loginStyle.login_div}>
<span className={loginStyle.login_title}>keylog</span>
<form onSubmit={login} className={loginStyle.login_form}>
<div className={loginStyle.login_input_div}>
<div className={`${loginStyle.login_emoji} btlr`}>
<i className={'fa-solid fa-user'}></i>
</div>
<input
type='text'
value={id}
className={`${loginStyle.login_input_text} btrr`}
placeholder='ID'
required
onChange={(e) => {
setId(e.target.value);
}}
></input>
</div>
<div className={`${loginStyle.login_input_div} mb10`}>
<div className={`${loginStyle.login_emoji} bb bblr`}>
<i className='fa-solid fa-lock'></i>
</div>
<input
type='password'
value={password}
className={`${loginStyle.login_input_text} bb bbrr`}
placeholder='Password'
required
onChange={(e) => {
setPassword(e.target.value);
}}
></input>
</div>
<div className={`loginErrMsg ${loginStyle.validateErrMsg}`}></div>
<button type='submit' className={loginStyle.login_btn}>
로그인
</button>
<div className={loginStyle.signup_btn_div}>
아직 회원이 아니신가요?
<span className={loginStyle.signup_btn} onClick={() => router.push('/signup')}>
회원가입
</span>
</div>
</form>
</div>
);
};
export default Login;
signIn
로그인 화면에서 제일 중요한 부분은 signIn 함수이다. 첫번째 인자로 'credentials' 를 넣어주면 [...nextauth].tsx의 authorize 함수에서 가져와서 사용할 수 있다. 두번째 인자에 사용자 입력한 id 값과 pw, redirect 여부등의 옵션 값들을 담아서 전달할 수 있다.
로그인 이후 이전화면으로 돌아가야하기에 따로 redirect 옵션을 사용하지 않고 .then에서 로그인 성공시 router.back을 사용하여 이전화면으로 이동시켜줬다.
만약 redirect 를 사용하고 싶다면 redirect 옵션 값을 true로 주고 callbackUrl 프로퍼티에 redirect 할 url 주소를 같이 전달한다.([...nextauth].tsx의 callback 프로퍼티에 redirect 함수가 정의되어있어야 함.)
/components/navbar.tsx
import { signOut, useSession } from 'next-auth/react';
const Navbar = () => {
//session : 로그인한 사용자 정보, 토큰 정보
//status: 로그인 인증 상태(unauthenticated, authenticated, loading)
const { data: session, status } = useSession();
//닉네임
const [nickname, setNickname] = useState(session?.user?.name);
//블로그 이름
const [blogName, setBlogName] = useState(session?.user?.blogName);
//이메일
const [email, setEmail] = useState(session?.user?.email!);
useEffect(() => {
// 세션이 만료된경우 계정관리창 닫기
if (status === 'unauthenticated') {
setOpenModal(false);
}
}, [status]);
...중략...
const logout = async () => {
const url = window.location.href;
//로그아웃 성공후 현재 화면으로 redirect
await signOut({ redirect: true, callbackUrl: url });
};
return (
... 중략...
)
}
session 데이터 사용
로그인 이후 사용자의 세션 정보는 위와 같이 useSession 함수를 사용하여 사용할 수 있고 status 값을 이용해 현재 이 사용자가 로그인을 한 사용자인지 아닌지의 여부도 확인할 수 있다.
signOut
로그아웃시 사용하는 함수이다. redirect 를 true 로 주면 로그아웃 후 callbackUrl로 이동할 수 있다.
3. middleware에서 로그인 여부로 접근제한 기능 구현
미들웨어에서 로그인 이후 발급받은 jwt를 사용하여 로그인하지 않은 사용자에 대해 특정 페이지 접근을 제한할 수 있다.
프로젝트 루트 경로에 middleware.tsx를 아래와 같이 작성한다.
middleware.tsx
import type { NextRequest, NextFetchEvent } from 'next/server';
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
const secret = process.env.SECRET;
export async function middleware(req: NextRequest, event: NextFetchEvent) {
//로그인이 되어 있을 경우 토큰이 존재
const token = await getToken({ req, secret, raw: true });
console.log('token:' + token);
const { pathname } = req.nextUrl;
//로그인이 되어 있는 경우 로그인, 회원가입 화면 접근 불가
if (pathname.startsWith('/login') || pathname.startsWith('/signup')) {
if (token) {
return NextResponse.redirect(new URL('/', req.url));
}
}
//로그인이 되어 있지 않은 경우 글 쓰기 화면 접근 불가
if (pathname.startsWith('/write')) {
if (!token) {
return NextResponse.redirect(new URL('/', req.url));
}
}
}
export const config = {
matcher: ['/login', '/signup', '/write'],
};
next-auth/jwt 에서 getToken 함수를 사용하여 현재 이 사용자가 유효한 사용자인지 확인한다. getToken 함수는 로그인을 하지 않아 토큰이 없거나 토큰이 만료된 사용자의 경우 null 을 반환한다. (https://next-auth.js.org/tutorials/securing-pages-and-api-routes 참고)
next auth를 사용하여 로그인 기능부터 세션 데이터 사용, 인증여부에 따른 접근제한 기능을 간편하게 구현해봤다. Oauth 를 사용한 소셜로그인기능을 구현해보지 못한 아쉬움이 크다. 구현과정은 크게 어렵지 않고 next-auth 공식 홈페이지와 레퍼런스가 많으니 추후 추가하게되면 포스팅 예정.