이 글은 24.04.13 기준으로 작성되었습니다. 따라서 이후에는 NextAuth가 업데이트 되었을 수 있습니다.
Next.js 공식문서 튜토리얼을 통해 처음 NextAuth를 알게 되었고 이를 활용하여 로그인 인증을 구현했습니다.
이후, NextAuth 없이도 로그인 인증을 구현했습니다.
NextAuth
Next.js 애플리케이션에서 사용할 수 있는 인증 및 인가 솔루션입니다. 이를 사용하면 다양한 인증 공급자(예: Google, Facebook, GitHub 등)를 통해 사용자를 인증하고, OAuth 및 OpenID Connect를 통해 사용자의 프로필 및 권한 정보를 가져올 수 있습니다.
기술 사양
이 글은 다음과 같은 기술 사양을 기준으로 작성되었습니다.
- 프론트엔드 : Next.js 14버전 이상
- 백엔드 : Node.js(20버전 이상) + Express(4버전)
- 데이터베이스 : MongoDB + mongoose
- NextAuth : beta버전(v5)
결론부터 말하자면 별도의 백엔드 서버가 있을 경우 NextAuth를 사용하여 클라이언트에서 로그인 인증을 구현하는 것은 추천하지 않습니다. 그 이유는 2개의 JWT를 관리해야 하고 구현하는 과정도 복잡하기 때문입니다.
이 글 뒷 부분에서는 NextAuth을 사용하지 않고 로그인 인증을 구현하는 방법도 설명하고 있습니다. 중간 과정 이야기가 필요없는 분들은 생략하고 바로 뒷 부분으로 넘어가도 됩니다.
또한 24.04.13 기준 NextAuth beta 버전(v5)가 아직 안정화된 상태가 아니라서 v4를 사용할 것을 권장합니다.
현재 NextAuth beta 버전에 네이버 로그인 오류가 있습니다.
NextAuth 사용하기
Next.js 공식문서 튜토리얼 Chapter 15에서는 14버전와 호환되는 beta버전을 설치하라고 안내하고 있습니다. 그래서 beta 버전을 설치했습니다.
npm install next-auth@beta
NextAuth의 기본적인 사용법은 귀찮아서 설명하지 않겠습니다. 대신 괜찮은 자료들을 추천하겠습니다.
- Credentials 방식(로컬 로그인) : Next.js 공식문서 튜토리얼 Chapter 15
- OAuth 방식(카카오 로그인) : https://msm1307.tistory.com/151
참고로, NextAuth를 사용해서 프론트엔드에서 로그인 인증이 가능한 이유는 Next.js는 풀스택 프레임워크로 Server Actions, Route Handler 등에서 서버측 코드를 실행할 수 있기 때문입니다. 따라서 Node.js 환경에서 실행할 수 있는 코드를 실행할 수 있다.
백엔드 서버 인증 토큰 가져오기
NextAuth는 로그인 성공시 클라이언트 세션을 생성하고 저장합니다. 클라이언트 세션은 기본적으로 JWT이며 만료일은 30일입니다. 이는 session옵션에서 변경 가능합니다.
참고로 클라이언트 세션이 클라이언트 인증 토큰입니다. 세션이 생성되었으니까 당연히 세션 ID가 쿠키에 저장됩니다. 세션이 익숙하지 않다면 세션에 대해 학습하는 것을 추천드립니다.
그러나 이 클라이언트 세션은 백엔드 서버에서 사용할 수 없습니다. 백엔드 서버에서도 리소스를 수정하고 삭제할 때 권한이 필요하기 때문에 인증 토큰이 필요합니다. 따라서 로그인 성공시 백엔드 서버에서 다음과 같은 작업을 수행해야 합니다.
- (OAuth의 경우) 최초 로그인시 데이터베이스에 유저 정보를 저장한다.
- 서버용 인증 토큰(JWT)을 생성한 후 클라이언트에 전달한다.
- 클라이언트 세션에 저장한다.
다행히도 NextAuth는 해당 작업들을 수행할 수 있는 callbacks을 지원하고 있습니다. NextAuth의 callbacks은 다음과 같은 순서로 실행됩니다.
- signIn
- jwt
- session
이 중에서 jwt와 session 2가지 callback을 활용하면 백엔드에서 받아온 인증 토큰을 클라이언트 세션에 저장할 수 있습니다.
jwt 콜백은 로그인 성공 또는 클라이언트 세션에 접근시 호출됩니다. 여기서 반환된 값은 암호화되어 쿠키에 저장됩니다. 또한, JWT 세션을 사용하는 경우에만 호출됩니다. 더 자세한 내용은 공식문서를 참고하길 바랍니다.
여기서 백엔드 서버와 통신하여 서버측 인증 토큰을 받아와서 클라이언트측 JWT에 저장하면 됩니다. 아시다시피 JWT에는 값을 저장할 수 있습니다. 만약, 서버측 인증 토큰이 JWT이면 클라이언트측 JWT에 서버측 JWT가 들어있는 구조가 되는 것이다.
callbacks: {
async jwt({ user, token, account, profile }) {
/*
Credentials 방식이면 user, OAuth 방식이면 profile에 유저 정보가 들어있을 것이다.
이를 사용하여 백엔드와 통신해서 서버측 인증 토큰을 받아온다.
코드가 남아있지 않아서 대충 적어봤다.
*/
const serverToken = await getServerToken();
token.serverToken = serverToken
return token
}
}
session 콜백을 통해 서버측 인증 토큰을 클라이언트 세션에 명시적으로 전달해야 클라이언트에서 서버측 인증 토큰에 접근할 수 있습니다.
session 콜백에서는 클라이언트 세션을 반환합니다.
callbacks: {
async session({ session, token, user }) {
session.accessToken = token.serverToken
return session
}
}
클라이언트에서 서버측 인증 토큰에 접근할 수 있어야 이후 클라이언트와 서버간 API 요청에서 이를 서버에 전달해줄 수 있습니다.
import { auth } from "../auth"
export default async function UserAvatar() {
const session = await auth() // 클라이언트에서 세션에 접근
if (!session.user) return null
return (
<div>
<img src={session.user.img} alt="User Avatar" />
</div>
)
}
문제점 : 2개의 JWT 관리
그러나, 위 방식은 2개의 JWT 토큰을 관리해야하는 문제가 있습니다.
바로 클라이언트 세션과 서버 인증 토큰입니다. 이는 동기화 문제를 발생시킬 수 있습니다.
2개의 토큰을 동기화 해주지 않으면 클라이언트는 로그인 상태이지만, 서버는 로그인이 되어 있지 않는 경우가 있을 수 있습니다. 아직 NextAuth에서 동기화 해주는 기능을 제공하지 않으므로 개발자가 직접 처리를 해야되고 이 작업은 엄청 번거로울 것입니다.
NextAuth 없이 로그인 구현
깃허브 링크도 같이 첨부합니다.
로컬 로그인, 로그아웃, 네이버 로그인 순으로 설명하겠습니다. 공통인 부분은 로컬 로그인과 네이버 로그인 모두 해당되는 부분입니다.
[로컬 로그인] 프론트엔드 - Server Action으로 백엔드 서버에 로그인 API 요청
로그인은 세션 방식으로 구현했기 때문에 백엔드 서버는 응답으로 세션 쿠키를 전달합니다.
여기서 주의할 점은 Server Action은 브라우저가 아닌 프론트엔드 서버에서 실행하기 때문에 백엔드 서버에서 전달한 세션 쿠키를 수동으로 저장해줘야 한다.
이 코드를 제대로 이해하고 싶으면 아래의 내용을 학습하면 됩니다.
// lib/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export async function authenticate(prevState: string | undefined, formData: FormData) {
const email = formData.get('email');
const password = formData.get('password');
const cookieStore = cookies();
const maxAge = 30 * 24 * 60 * 60 * 1000;
try {
const response = await fetch(`${process.env.SERVER_URL}/api/auth/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
const json = (await response.json()) as { message: string };
if (response.status === 401) throw new Error(json.message);
response.headers.getSetCookie().forEach((items) => {
const [key, str] = items.split('=');
const [value] = str.split('; ');
cookieStore.set(key, value, { httpOnly: true, secure: false, maxAge: maxAge });
});
} catch (error) {
if (error instanceof Error) {
return error.message;
}
throw error;
}
redirect('/');
}
[로컬 로그인] 프론트엔드 - <form>의 action 속성으로 Server Action 호출
form 태그의 action 속성을 통해 Server Action을 호출할 수 있습니다.
// components/login/login-form
'use client';
import { authenticate } from '@/lib/actions';
function LoginForm() {
...
const [errorMessage, dispatch] = useFormState(authenticate, undefined);
...
return (
<form action={dispatch}>
...
<div className="mt-5">
<LoginButton />
</div>
</form>
);
}
export default LoginForm;
[공통] 백엔드 - cookie-parser와 express-session 설치 및 적용
cookie-parser
Express 애플리케이션에서 클라이언트 쿠키를 쉽게 파싱할 수 있도록 도와주는 미들웨어입니다. 이 미들웨어를 사용하면 클라이언트가 서버로 전송한 쿠키를 파싱하여 JavaScript 객체로 변환할 수 있습니다. 이를 통해 쿠키에 저장된 데이터를 쉽게 읽고 사용할 수 있습니다.
- 요청에 동봉된 쿠키를 해석해 req.cookies 객체로 만듭니다.
- 서명된 쿠키는 req.signedCookies 객체에 들어 있습니다.
express-session
Express 프레임워크를 사용하는 Node.js 웹 애플리케이션에서 세션 관리를 위한 미들웨어입니다. 이를 사용하면 세션을 쉽게 설정하고 유지할 수 있으며, 사용자의 상태를 저장하고 관리할 수 있습니다. 로그인 등의 이유로 세션을 구현하거나 특정 사용자를 위한 데이터를 임시적으로 저장해둘 때 매우 유용합니다.
- 세션은 사용자별로 req.session 객체 안에 유지됩니다.
cookie-parser와 express-session, session-file-store, cors 설치합니다.
session-file-store은 세션을 서버가 재시작 되어도 세션을 유지하기 위해 설치했고 cors는 cross-origin간 쿠키 전송을 허용하려고 설치했는데 프론트엔드에서 Server Action을 사용하면 서버끼리 통신하는 것이라 없어도 상관없습니다.
npm i cookie-parser express-session session-file-store cors
설치한 미들웨어를 서버 애플리케이션에 적용합니다.
작동원리를 간단히 설명해보면 로그인 성공시 우리는 세션에 사용자 정보를 저장할 것입니다. express-session 미들웨어는 세션 ID를 생성합니다. 그러면 서버는 응답으로 세션 ID를 쿠키에 저장합니다. 이를 세션 쿠키라고 합니다.
이후 요청 때 클라이언트는 세션 쿠키를 서버에 보내고 cookie-parser가 이를 관리합니다.
// app.js
const express = require('express');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const dotenv = require('dotenv');
const cors = require('cors');
const fileStore = require('session-file-store')(session);
dotenv.config();
...
const app = express();
app.set('port', process.env.PORT || 3001);
...
app.use(
cors({
credentials: true,
})
);
...
// req.cookies, 쿠키는 클라이언트에서 위조하기 쉬우므로 비밀 키를 통해 만들어낸 서명을 쿠키 값에 붙임, req.signedCookies
app.use(cookieParser(process.env.COOKIE_SECRET));
const sessionOption = {
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
cookie: {
httpOnly: true,
secure: false, // 배포시 true
maxAge: 30 * 24 * 60 * 60 * 1000,
},
store: new fileStore(),
};
if (process.env.NODE_ENV === 'production') {
sessionOption.proxy = true;
sessionOption.cookie.secure = true;
}
app.use(session(sessionOption));
...
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send(err.message);
});
app.listen(app.get('port'), () => {
console.log(app.get('port'), '번 포트에서 대기 중!');
});
[로컬 로그인] 백엔드 - 로그인 구현
로그인 성공시 세션에 사용자의 ObjectId를 저장합니다. 세션에는 최소한의 정보만 저장하는 것이 좋습니다. 이후 이 ObjectId를 통해 데이터베이스에서 사용자 정보를 가져오면 됩니다.
// controllers/auth.js
const bcrypt = require('bcrypt');
exports.login = async (req, res, next) => {
const { email, password } = req.body;
try {
const exUser = await User.findOne({ email });
console.log(password, exUser.password);
if (exUser) {
const result = await bcrypt.compare(password, exUser.password);
if (result) {
req.session.userId = exUser._id; // 세션 저장
return res.status(200).json({ message: '로그인 성공' });
} else {
return res
.status(401)
.json({ message: '회원 정보가 일치하지 않습니다.' });
}
} else {
return res.status(401).json({ message: '가입 되지 않은 회원입니다.' });
}
} catch (error) {
console.error(error);
next(error);
}
};
[공통] 프론트엔드 - 로그아웃 API 요청
Server Action은 프론트엔드 서버에서 실행되어서 브라우저 쿠키에 접근할 수 없습니다. 따라서 수동으로 HTTP 헤더에 수동으로 세션 쿠키를 넣어줘야 합니다.
백엔드 서버에서 로그아웃이 성공적으로 완료되면 프론트엔드에서 쿠키를 수동으로 제거해줘야 합니다.
// lib/actions.ts
export async function signOut() {
const cookieStore = cookies();
const session = cookieStore.get('connect.sid')?.value;
try {
const response = await fetch(`${process.env.SERVER_URL}/api/auth/logout`, {
method: 'GET',
headers: {
Cookie: `connect.sid=${session}`,
credentials: 'include',
},
});
if (response.status !== 204) throw new Error('로그아웃 실패');
cookieStore.delete('connect.sid');
} catch (error) {
throw error;
}
}
[공통] 백엔드 - 사용자 정보를 가져오는 미들웨어 구현
클라이언트가 세션 쿠키를 서버에 전달하면 이전 단계에서 설치하고 적용했던 express-session 미들웨어가 세션을 req.session 저장합니다. 세션 쿠키가 세션 ID이므로 이를 통해 현재 사용자의 세션를 찾을 수 있습니다.
아까 로그인에 성공했을 때 사용자의 ObjectId를 세션에 저장했기 때문에 데이터베이스에서 사용자 정보를 가져올 수 있습니다. 만약 존재하지 않는다면 로그인 되지 않은 상태입니다.
이후 단계에서 사용자 정보를 사용하기위해 req.user에 저장하겠습니다.
exports.getUser = async (req, res, next) => {
console.log('cookies', req.cookies);
console.log('signedCookies', req.signedCookies);
console.log('session', req.session);
console.log('sessionID', req.sessionID);
console.log('-------------------------------------------------------');
if (!req.session.userId) return next();
const exUser = await User.findById(req.session.userId);
req.user = exUser;
next();
};
모든 요청에서 실행되어야 하기 때문에 express-session 아래에 적용하겠습니다.
// app.js
...
app.use(session(sessionOption));
app.use(getUser);
...
[공통] 백엔드 - 로그인 여부를 확인하는 미들웨어 구현
이전 단계에서 getUser 미들웨어를 통해 사용자 정보를 req.user에 저장했습니다. 이를 통해 이제 로그인 여부를 파악할 수 있습니다.
// middlewares/index.js
exports.isLoggedIn = (req, res, next) => {
if (req.user) {
next();
} else {
res.status(403).send('로그인이 필요합니다.');
}
};
exports.isNotLoggedIn = (req, res, next) => {
if (!req.user) {
next();
} else {
res.status(403).send('로그인한 상태입니다.');
}
};
로그인이 되어있지 않은 상태에서 로그아웃을 수행하는 것을 방지할 수 있습니다.
// routes/auth.js
router.get('/logout', isLoggedIn, logout);
[공통] 백엔드 - 로그아웃 구현
이렇게 세션을 삭제해주면 됩니다.
exports.logout = (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error(err);
res.status(500).json({ message: '세션 삭제 에러' });
} else {
res.status(204).end();
}
});
};
[OAuth 로그인] 네이버 로그인
OAuth에 대해 알지 못한다면 다음 내용을 학습할 것을 권장합니다.
일단 네이버 개발자 센터에서 네이버 로그인 API를 신청합니다.
여기서 주의할 점은 로그인 성공시 애플리케이션 등록할 때 입력한 Callback URL로 인증 코드를 보내주기 때문에 이후 단계에서 사용된다는 걸 인지하고 있어야 합니다.
지금부터 구현하는 모든 내용은 네이버 로그인 API 가이드를 참고했습니다. 제 설명에 부족한 부분이 있다면 해당 문서를 확인해보세요
[네이버 로그인] 프론트엔드 - 네이버 로그인 인증 요청 구현
프론트엔드에서 네이버 로그인을 클릭했을 때 네이버 로그인 화면으로 페이지가 이동되고 로그인에 성공했을 때 인증 코드(code)를 반환받는 단계입니다.
이 컴포넌트는 서버 컴포넌트라서 Node.js 모듈인 crypto를 사용할 수 있습니다.
redirect_uri은 네이버 로그인 API을 신청했을 때 등록했던 Callback URL이고 state는 랜덤으로 생성한 난수입니다.
// components/social-login-button.tsx
const crypto = require('crypto');
const random = crypto.randomBytes(16);
const state = BigInt('0x' + random.toString('hex')).toString();
const NAVER_CLIENT_ID = process.env.NAVER_CLIENT_ID;
const NAVER_REDIRECT_URL = 'http://localhost:3000/api/auth/callback/naver';
const NAVER_AUTH_URL = `https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=${NAVER_CLIENT_ID}&redirect_uri=${NAVER_REDIRECT_URL}&state=${state}`;
function SocialLoginButton() {
return (
<div className="mt-10">
<a
className="relative mb-2 flex h-[52px] w-full items-center justify-center rounded-xl border-[1px] border-[#ebebeb] font-bold text-[#222]"
href={NAVER_AUTH_URL}
>
...
네이버로 로그인
</a>
...
</div>
);
}
export default SocialLoginButton;
[네이버 로그인] 프론트엔드 - Callback URL에 대한 Route Handler 구현
app 폴더 안에 api/auth/[…nextauth]/route.ts 파일을 만듭니다. ...nextauth은 catch-all 세그먼트이며 꼭 nextauth일 필요는 없습니다. 원하는 이름을 지정하면 됩니다.
동작원리를 간단하게 설명하면 네이버 로그인에 성공하면 애플리케이션 등록할 때 입력했던 Callback URL로 리다이렉트되며 code(인증토큰)와 state(사이트 간 요청 위조 공격을 방지하기 위해 애플리케이션에서 생성한 상태 토큰값)를 넘겨줍니다.
네이버 로그인에 성공했어도 백엔드 서버는 아직 이 상황을 알지 못합니다. 따라서 백엔드 서버에 이 사실을 알릴 필요가 있습니다. 이 Route Handler를 통해 code과 state를 백엔드 서버에 전달할 수 있습니다.
백엔드 서버에서 응답이 성공적으로 오면 로컬 로그인과 마찬가지로 세션 쿠키를 수동으로 저장합니다. Route Handler도 브라우저가 아닌 프론트엔드 서버에서 실행합니다.
import { type NextRequest } from 'next/server';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export async function GET(request: NextRequest, { params }: { params: { nextauth: string[] } }) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const provider = params.nextauth[1];
const cookieStore = cookies();
const maxAge = 30 * 24 * 60 * 60 * 1000;
if (!provider) return;
const response = await fetch(`${process.env.SERVER_URL}/api/auth/${provider}`, {
method: 'POST',
body: JSON.stringify({ code, state }),
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (response.status !== 200) throw new Error(`${provider} 로그인 실패`);
response.headers.getSetCookie().forEach((items) => {
const [key, str] = items.split('=');
const [value] = str.split('; ');
cookieStore.set(key, value, { httpOnly: true, secure: false, maxAge: maxAge });
});
redirect('/');
}
[네이버 로그인] 백엔드 - access token 발급 및 네이버 회원 프로필 조회 구현
프론트엔드에서 전달받은 code와 state를 통해 access token을 발급받고 access token을 통해 회원 프로필을 조회할 수 있습니다.
최초 로그인이라면 데이터베이스에 회원 정보를 저장합니다.
데이터베이스에서 사용자 조회가 성공적으로 완료되면 로컬 로그인과 똑같이 세션에 사용자의 ObjectId를 저장합니다.
// controllers/auth.js
exports.naverLogin = async (req, res, next) => {
const { code, state } = req.body;
try {
const {
data: { access_token, expires_in, refresh_token },
} = await axios.post(
`https://nid.naver.com/oauth2.0/token?grant_type=authorization_code&client_id=${NAVER_CLIENT_ID}&client_secret=${NAVER_CLIENT_SECRET}&redirect_uri=${NAVER_REDIRECT_URL}&code=${code}&state=${state}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
},
}
);
const {
data: { response },
} = await axios.post(
'https://openapi.naver.com/v1/nid/me',
{},
{
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
},
}
);
const exUser = await User.findOne({
snsId: response.id,
provider: 'naver',
});
if (exUser) {
req.session.userId = exUser._id;
} else {
const newUser = await User.create({
email: response?.email,
nick: response.name,
snsId: response.id,
provider: 'naver',
});
req.session.userId = newUser._id;
}
req.session.auth = {
access_token,
expires_in,
refresh_token,
};
res.status(200).json({ message: '네이버 로그인 성공' });
} catch (error) {
console.error(error);
return next(error);
}
};
마치며
NextAuth 없이 로그인 인증을 구현하는 과정은 HTTP stateless, 세션, 쿠키, OAuth를 이해하는데 큰 도움이 되었습니다.
'Next.js' 카테고리의 다른 글
Next.js 14에서 Context를 사용해서 토스트 메시지 구현하고 최적화하기 (1) | 2024.05.11 |
---|---|
Next.js 14에서 원격 이미지 최적화하기 (1) | 2024.02.16 |
Next.js 14에서 Tailwind CSS 직접 적용해보기 (1) | 2024.02.12 |
Next.js 14 프로젝트 초기 설정 (0) | 2024.02.10 |