[Develog] 골치아픈 로그인 및 인증 - 2. 적용 (Next-auth@v5/Auth.js)
2024.04.24- -
1. 구 NextAuth.js, 현 Auth.js
Auth.js는 모든 JavaScript 런타임이 구성하는 모든 플랫폼에서 모던 애플리케이션의 "인증(authentication)" 부분을 위해 표준 웹 API를 기반으로 구축된 오픈 소스 패키지 모음이다.
기존에 명칭은 next-auth로 많이 알려졌으나 next-auth@5.0.0-beta 이후 버전부터 @auth/* 네임 스페이스가 생겼고 Auth.js로 명칭이 바뀌었다. (이전 버전은 계속해서 next-auth로 패키지 이름이 구성된다.)
2. 무슨 기능이 있나?
2-1. 유연하고 사용하기 쉬움
- 모든 OAuth 서비스와 함께 동작될 수 있도록 설계되어 2.0+, OIDC를 지원한다.
- 많은 인기 로그인 서비스를 기본 지원한다.
- Email/Passwordless 인증을 지원한다.
- 모든 백엔드 (Active Directory, LDAP 등)에서 stateless 인증을 지원한다.
- 런타임에 구애받지 않고 어디서나 실행가능하다. (Vercel Edge Functions, Node.js, Serverless, etc.)
2-2. 우리가 가진 데이터와의 연동
Auth.js는 데이터베이스와 함께도, 데이터베이스 없이도 사용할 수 있다.
- 데이터를 계속해서 제어할 수 있는 오픈 소스 솔루션이다.
- MySQL, MariaDB, Postgres, Microsoft SQL Server, MongoDB, SQlite 등을 기본 지원한다.
- 인기 호스팅 업체의 데이터베이스와도 잘 작동한다.
2-3. 내장 보안
- 비밀번호 없는(passwordless) 로그인 메커니즘의 사용을 장려한다.
- 기본적으로 안전하도록 설계되었고 사용자의 데이터 보호를 위함 best practices를 쉽게 구현할 수 있게 한다.
- POST 라우트(sign in, sign out)에서 CSRF 토큰 사용 기본 쿠키 정책은 각 쿠키에 적합한 가장 제한적인 정책을 목표로 한다.
- JSON 웹 토큰을 사용하는 경우, 기본적으로 A256CBC-HS512로 암호화(JWE)된다.
- 탭/창 동기화 및 세션 폴링 기능을 통해 수명이 짧은 세션을 지원한다.
- 오픈 웹 애플리케이션 보안 프로젝트에서 발표한 최신 지침을 구현하려고 시도한다.
3. 요구사항
이번 프로젝트에서는 가장 기본적인 이메일, 비밀번호 인증 전략을 사용할 것이었기 때문에 Auth.js가 갖고 있는 많은 provider 중에서 가장 기본이라고 할 수 있는 Credential Provider 방식으로 구현을 진행한다.
설치부터 설정은 버전이 업데이트 될 때마다 바뀔 수도 있어 사실 공식문서를 보는 것이 가장 정확하다. 때문에 해당 내용을 여기서 기술하지는 않을 것이다. 설치부터 시작할 사람은 아래 링크를 참고하면 된다.
우선 나는 JWT 방식으로 백엔드의 인증 부분을 구현했기 때문에 이를 적극 활용할 것이다. 이를 위해서 백엔드에게 로그인 API를 요청하면 토큰을 넘겨주고 해당 토큰을 받아 Auth.js가 관리할 수 있도록 연결해주면 된다.
access token을 관리하는 것은 크게 어렵지 않았으나 refresh token 기능을 넣으면서 access token을 계속해서 갱신시켜주고 유효성을 검증하는 과정이 상당히 복잡하다는 사실을 느꼈다.
사실 Auth.js에서 만들어주는 session은 default값으로 특정 시간이 지나면 만료되도록 설정이 되어있고 물론 사용자가 그 시간을 설정해줄 수도 있다. 하지만 access token만으로 인증을 하게 되면 탈취 시 해당 토큰으로 내 명의로 어떤 짓을 할 지 모르는 사태가 벌어질 수도 있기 때문에 refresh token을 통해 access token을 주기적으로 갱신시켜주는 전략을 사용해야 한다.
아래는 이러한 전략을 구현한 코드이다.
callbacks: {
// 토큰 관련 action 시 호출되는 Callback
async jwt({ token, user }) {
if (user) {
// Initial Login에만 user가 존재
return {
...user,
accessToken: user.accessToken,
expiresAt: Math.floor(Date.now() / 1000 + user.expiresIn),
refreshToken: user.refreshToken,
};
} else if (Date.now() < (token.expiresAt as number) * 1000) {
console.log(Date.now(), (token.expiresAt as number) * 1000);
// 첫 로그인 이후 토큰 access
return token;
} else {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/auth/refresh-token`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Cookie: `refreshToken=${token.refreshToken}`,
},
credentials: "include",
}
);
const newTokens = await response.json();
if (!response.ok) throw response.body;
return {
...token,
accessToken: newTokens.accessToken,
expiresAt: Math.floor(Date.now() / 1000 + newTokens.expiresIn),
// refreshToken: newTokens.refresh_token ?? token.refresh_token,
};
} catch (error) {
console.error("Error refreshing access token", error);
signOut({ redirect: false });
return { ...token, error: "RefreshAccessTokenError" as const };
}
}
},
// Session 관련 action 시 호출되는 callback
session({ session, token }) {
session.user = token as any;
return session;
},
},
위 코드는 jwt콜백과 session 콜백의 코드로 각 콜백은 그것과 관련된 이벤트가 발생 시 호출되는 함수이다.
- jwt: JWT 토큰이 만들어질 때, 혹은 업데이트될 때 호출됨. 반환 값은 암호화되며 쿠키에 저장됨
- session: session이 확인되는 모든 순간에 호출됨
CredentialsProvider에 대한 코드는 정형화되어있기 때문에 첨부하지는 않았지만 해당 코드에서 중요한 부분은 백엔드로 로그인 요청(/auth/sign-in)을 보내고 서버로부터 사용자에 대한 정보와 더불어 두 토큰(access, refresh)에 대한 값을 응답으로 반환받으면, jwt 콜백에서 'user' 이름으로 값을 전달 받아 사용할 수가 있다.
하지만 jwt 콜백에서 'user' 인자가 존재하는 경우는 첫 로그인 시일 때가 유일하기 때문에 그 경우에 token에 user 정보를 저장하도록 해준다.
user가 존재하지 않는 경우는 처음에 로그인을 하고 나서 다시 해당 콜백이 호출된 경우이기 때문에 이 경우에는 앞서 token에 user 정보를 저장했으니 이 token 값을 그대로 반환하도록 해주었다.
하지만 이때, user가 존재하지 않으면서도 user 정보에 포함되어 있던 토큰의 유효 시간을 계산해서 이 토큰이 유효한 경우(else if문)에만 토큰을 바로 넘겨주고 그렇지 않은 경우(else문) 백엔드에 token을 refresh 하는 요청을 보내 새 access token을 받아 token에 다시 넣어주도록 하는 과정을 진행해주어야 한다.
- 유효하지 않은 경우 백엔드로부터 error response가 넘어오기 때문에 response.ok가 false값을 가지게 된다.
- 백엔드에 refresh 요청을 보낼 때 token으로부터 refreshToken을 가져와 header의 cookie 필드에 담아주어야 한다.
그래서 이때 백엔드 측에서 Unauthroized 401 에러를 반환했다면 쿠키에 담아 보낸 refresh token이 만료되었다는 의미이므로 이를 명시하기 위해 session에 저장될 객체에 error 속성을 추가해주도록 하였다.
4. middleware로 라우트 보호하기
middleware는 Next13 버전 이후에서 사용할 수 있는 기능으로 root 디렉터리에 middlware.ts(or .js)파일을 만들어 그 안에 middleware 함수를 구현하면 적용이 된다.
미들웨어는 페이지를 렌더링하기 전에 서버 측에서 실행되는 함수로, 특정 요청 전에 무언가를 수행할 수 있게 하는 기능을 가진다.
Middlware에서는 NextRequest 객체와 NextResponse 객체에 접근할 수 있어 다양한 기능을 구현할 수 있다.
import { auth } from "./auth";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_HOST}/login`);
}
if (
request.nextUrl.pathname.startsWith("/admin") &&
session.user.level !== "admin"
) {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_HOST}/`);
}
return NextResponse.next();
}
// See "Matching Paths" below to learn more
export const config = {
matcher: ["/course/:path+", "/admin/:path*"],
};
위 미들웨어 코드는 생각보다 단순하여 request와 response 객체를 적절히 활용하여 현재 요청의 url을 가져오거나 응답 전에 리다이렉트 시키거나 하는 작업이 가능하다.
우리는 next-auth로 session에 대한 정보를 서버, 클라이언트에서 모두 가져올 수 있기 때문에 session을 가져와 이를 바탕으로 페이지 리다이렉트를 결정하게 하였다.
물론 모든 라우트에 접근 시 이 미들웨어가 호출되는 것이 아니라 아래 정의된 config의 matcher 배열에 이 미들웨어를 실행하게 할 디테일한 url을 명시하도록 할 수 있다.
또, 원하는 경로를 일일히 다 적기는 힘든 일이기 때문에 위 코드와 같이 정규 표현식을 적절히 활용할 수가 있다.
내 경우에 /admin 으로 시작하는 모든 경로에 대해, '/course' 는 제외하고 /course로 시작하는 모든 경로에 대해 실행될 수 있도록 한 것이다.
5. 세션 만료시키기
앞서 3장에서 refresh에 실패한 경우에는 다시 session을 존재하지 않는 상태로 만들어 4장에 명시했던 것처럼 '/login' 페이지로 리다이렉트 되도록 유도해야 하기 때문에 refresh 실패 시 session에 넣어주었던 error 속성의 유무로 이를 가능하게 할 수 있다.
이번 프로젝트에서는 로그인의 유무에 따라 헤더 부분의 구성이 달라지게 되는데 분리시킨 헤더 컴포넌트(@/components/Header.tsx)에서 useEffect 함수를 통해 session.user.error를 확인하여 next-auth에 정의된 signOut() 함수를 호출할 수가 있다.
그러면 놀랍게도 next.js에서 세션을 확인하기 위해 jwt 콜백이 실행되는 순간 refresh token의 유효하지 않은 경우 자동으로 signOut이 되어 쿠키에 토큰이 사라지고 세션도 만료되어 4장에서 정의한 미들웨어 matcher 주소에 접근하려는 경우 이를 막을 수 있게 되는 것이다.
const { data: session, status } = useSession();
useEffect(() => {
if (session && session.user.error === "RefreshAccessTokenError") {
console.log("세션 만료.");
signOut({ redirect: false });
}
}, [session]);
6. production 단계에서의 문제
6-1. 환경변수
이번 프로젝트를 진행함에 있어 개발을 할 때는 로컬 컴퓨터에 node와 npm modules을 직접 설치하는 방식으로 진행을 했지만 클라우드 서버에 올릴 때는 Dockerfile을 따로 만들어 컨테이너를 띄우는 방식으로 진행했다.
그렇기 때문에 로컬 개발 시에는 .env 파일로부터 환경 변수를 가져왔지만 docker를 사용하면 Dockerfile에 이를 명시해주어야 접근이 가능하다. 그렇기 때문에 이를 잘 명시해야 프로덕션 단계에서 문제없이 진행될 수 있다.
ENV NODE_ENV production
ENV PORT 80
ENV AUTH_SECRET secret
ENV NEXT_PUBLIC_BASE_URL=http://00.000.000.000:3000
ENV NEXT_PUBLIC_API_URL=http://00.000.000.000:3000
ENV NEXT_PUBLIC_BASE_HOST=http://00.000.000.00:80
EXPOSE 80
6-2. NextAuth option 설정
next-auth 라이브러리를 설정할 때는 auth.ts에 작성하게되는데 NextAuth라는 함수에 각종 옵션 속성을 지정할 수가 있다.
그 중에 trustHost를 true로 지정해주어야 API 호출을 백엔드와 '믿을 수 없는 출처'와 같은 오류 없이 가능하다.
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
trustHost: true,
pages: {
signIn: "/login",
newUser: "/signup",
},
session: {
strategy: "jwt",
},
....
'프로젝트' 카테고리의 다른 글
mysql 커넥션 풀 오류 해결 과정 (1) | 2024.08.26 |
---|---|
[프로젝트] 데이터를 불러오는 데 너무 오래걸리는 문제 해결 및 성능 개선(feat. 데이터베이스 join) (0) | 2024.06.20 |
[Devlog] 개발 시작부터 지금까지의 여정 (1) | 2024.06.02 |
[Develog] 골치아픈 로그인 및 인증 - 1. 이론 정리 (세션, 쿠키, JWT) (0) | 2024.04.13 |
[Develog] NestJS와 TypeORM (Entity 클래스 쉽게 만드는 법) (0) | 2024.03.11 |
소중한 공감 감사합니다