내가 하고 있는 프로젝트는 소셜 로그인을 제공하지만, 모든 처리는 백엔드에서 처리된다.
인가코드를 발급받는 로직도 백에서 처리되므로 이에 대한 설명은 이 게시글에 작성하지 않았다.
내가 사용한 auth의 소셜 로그인 로직은 다음과 같다.
Link
사용)temp-token
을 가지고 access-token
발급access-token
으로 회원 정보 호출 access-token
과 회원 정보 관리백으로부터 발급받은
access-token
을 세션으로 관리하고자 하는 분들이 아니라면, 시간 절약을 위해 다른 글을 보시는걸 추천드립니다.
Next.js 애플리케이션에 인증을 추가하려면 Auth.js를 사용할 수 있다.
세션 관리, 로그인 및 로그아웃, 기타 인증 측면과 관련된 많은 복잡성을 추상화해준다.
물론, 이러한 기능을 수동으로 구현할 수 있지만 시간이 많이 걸리고 오류가 발생하기 쉽다.
Auth.js는 Next.js 애플리케이션의 인증을 위한 통합 솔루션을 제공하여 프로세스를 단순화한다.
원래는 NextAuth.js 였는데, Auth.js로 이름이 바뀌었다.
먼저, auth.js를 설치해준다.
타입스크립트를 사용한다면, @auth/core
도 같이 설치해준다.
// yarn, yarn berry
yarn add next-auth@beta @auth/core
// npm
npm install next-auth@beta @auth/core
다음으로 애플리케이션에 대한 비밀 키를 생성해준다.
이 키는 세션 쿠키를 암호화해서 사용자 세션의 보안을 보장하는데 사용된다.
직접 원하는 값으로 설정해줘도 되지만, 보안을 위해선 더 안전하게 관리하는게 좋으니 랜덤한 키값을 생성해줘서 추가해주었다.
openssl rand -base64 32
별다른 라이브러리를 설치하지 않아도 위 명령어를 터미널에 입력하면, 키 값이 터미널에 출력될 것이다.
이제 생성한 비밀 키를 .env
파일에 AUTH_SECRET
변수로 추가해준다.
AUTH_SECRET=your-secret-key
Vercel로 배포한 프로덕션에서도 인증이 작동하려면, Vercel 프로젝트에도 환경 변수를 업데이트해야 한다.
총 4개의 파일을 만들어줄 것이다.
나는 타입스크립트를 사용하므로 확장자는 모두 .ts
이다.
자바스크립트를 사용한다면, .js
확장자를 사용하면 될 것이다.
auth.config.ts 파일로 auth.ts와 따로 관리하도록 config 파일을 두었다.
굳이 파일을 분리하지 않고 auth.ts 파일에 모두 작성해도 된다.
auth.ts 파일은 세션 관리를 위한 대부분의 로직이 들어가는 파일이다.
route.ts 파일은 auth에서 제공하는 인증을 처리하기 위한 /api/auth/
하위 경로의 동적 일치로 라우트를 제공한다.
또한, 기본 구성에서 반환하는 handlers
객체로 라우트의 GET
과 POST
함수를 매핑한다.
auth의 jwt, session, user에 대한 데이터 타입을 선언해줄 수 있다.
만약 auth에서 제공하는 값들에 추가로 설정하고자 하는 값이 있다면, 추가하면 된다.
필수 파일에 대한 기본 코드는 다음과 같다.
// src/auth.ts
import NextAuth from 'next-auth'
export const {
handlers,
signIn,
signOut,
auth,
unstable_update: update // Beta!
} = NextAuth({
providers: [
// ...
],
session: {
strategy: 'jwt', // JSON Web Token 사용
maxAge: 60 * 60 * 24 // 세션 만료 시간(sec)
},
pages: {
signIn: '/signin' // Default: '/auth/signin'
},
callbacks: {
signIn: async () => {
return true
},
jwt: async ({ token, user }) => {
return token
},
session: async ({ session, token }) => {
return session
}
}
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// export const runtime = 'edge' // Optional!
auth가 사실 블로그가 많아서 쉽게 끝날 줄 알았는데, 나는 이 auth를 위해 4일을 투자했다.
공부를 위해서가 아니라, 동작 되게 하기 위해서..
블로그대로, 공식문서대로 하라는대로 했지만 에러가 뜨고 동작하지 않아서 많이 헤맸다.
먼저, 현재 라우트 코드를 나는 아래와 같이 작성해주었다.
// src/app/api/auth/[...nextauth]/route.ts
export { GET, POST } from '@/auth';
처음엔 나도 기본 코드대로 작성도 해보고, 아래처럼 작성도 해봤다.
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
하지만 자꾸 에러가 나서 next auth 깃 이슈를 방황하다 지금 코드처럼 수정하니 작동되어 이대로 진행했다.
작동하지 않는 auth를 작동시키기 위해 4일을 방황하다보니, auth.ts 파일에 모든 코드를 두는게 아니라 config 파일을 따로 두어 호출해 사용하는 식으로 수정하게 되었다.
코드 밑에 각 구성에 대한 설명을 작성해두었다.
// src/auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export const {
handlers: { GET, POST },
signIn,
signOut,
auth,
unstable_update: update, // Beta
} = NextAuth(authConfig);
// src/auth.config.ts
import type { NextAuthConfig, User } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
interface Authorize {
accessToken?: string;
}
type ExtendedUser = User & {
accessToken: string;
id: string;
};
export const authConfig: NextAuthConfig = {
providers: [
Credentials({
authorize: async (credentials: Authorize) => {
const { accessToken } = credentials;
let user: User = {
accessToken: '',
authProvider: '',
name: '',
email: '',
};
// 토큰이 있는 경우, 회원가입
if (accessToken) {
const response = await fetch(
`${process.env.NEXT_BASE_URL}/서버 회원 정보 호출 엔드포인트`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
},
);
const result = await response.json();
// 로그인 실패
if (!result.success || !response.ok) {
throw new Error('FAILED TO LOGIN');
}
user = {
accessToken,
authProvider: result.response.authProvider,
name: result.response.nickname,
email: result.response.email,
};
}
return user;
},
}),
],
session: {
strategy: 'jwt', // JSON Web Token 사용
maxAge: 60 * 60 * 24, // 세션 만료 시간(sec), refresh token 만료시간과 동일하게 적용
},
pages: {
signIn: '/',
signOut: '/',
},
callbacks: {
signIn: async ({ user }) => {
// 로그인 시에만 호출
if (user && (user as ExtendedUser).accessToken) return true;
return false;
},
jwt: async ({ token, user, trigger, session }) => {
// user 객체가 없다는 것은 단순 세션 조회를 위한 요청
// user 객체가 있다는 것은 로그인 시도
if (user?.accessToken) {
const extendedUser = user as ExtendedUser;
const newJwtToken = {
...token,
accessToken: extendedUser.accessToken,
name: extendedUser.name || 'anonymous',
email: extendedUser.email || 'anonymous',
authProvider: extendedUser.authProvider,
id: extendedUser.id,
};
return newJwtToken;
}
// update 시에만 session이 존재
// accessToken 만료로 인한 갱신, 기존 세션에 새로운 토큰을 반영
// update 호출시, jwt 콜백이 호출되며 trigger와 session 속성으로 정보가 전달된다.
// trigger는 갱신 이벤트이고, session은 갱신된 세션 정보이다.
if (
trigger === 'update' &&
session?.user.accessToken !== token.accessToken
) {
// 엑세스 토큰 재발급 후 세션의 엑세스 토큰 변경
const newJwtToken = {
...token,
accessToken: session.user.accessToken,
};
return newJwtToken;
}
// session에 저장할 정보를 반환
return token;
},
session: async ({ session, token }) => {
if (token && session.user.accessToken !== token.accessToken) {
// jwt callback에서 반환받은 token 값을 기존 세션에 추가한다.
const newSession = {
...session,
user: {
...session.user,
accessToken: token.accessToken,
name: token.name,
email: token.email,
authProvider: token.authProvider,
},
};
return newSession;
}
return session;
},
redirect: async ({ url, baseUrl }) => {
if (url) {
const { search, origin, pathname } = new URL(url);
const callbackUrl = new URLSearchParams(search).get('callbackUrl');
if (callbackUrl) {
return callbackUrl.startsWith('/')
? `${baseUrl}${callbackUrl}`
: callbackUrl;
}
// 로그인 성공 시 리다이렉트할 페이지
if (pathname === '/login/auth' || pathname === '/') {
return `${baseUrl}/home`;
}
if (origin === baseUrl) return url;
}
return baseUrl;
},
},
// JWT 암호화 키
secret: process.env.AUTH_SECRET,
};
코드만 봤을 땐 이게 무슨 소리인지 이해가 안됐다.
코드 리뷰를 할 때 다른 팀원도 이해가 안될 것 같아 주석을 추가로 작성해주었다.
추가로, auth에서 토큰 만료시 재발급 로직을 수행하도록 할 수도 있는데, 나의 경우엔 엑세스 토큰의 만료 시간이 5~20분 정도로 이 만료시간을 서버에서 토큰을 발급받을 때 같이 받아와야 한다.
매번 만료시간을 받기보단, 토큰이 만료되면 따로 재발급 api를 호출하는 식으로 하였다. 그리고 재발급 된 후엔 update 함수를 호출한다.
이 로직에 문제가 생기면, 그때 auth를 통한 토큰 재발급으로 수정할 것 같다.
나는 설명을 제대로 읽지 않고 바로 구현만 하려고 했어서 많이 헤맸는데, 기본 구성을 잘 이해하는걸 추천한다.
간단하게 기본 구성을 설명을 하자면, 아래와 같다.
NextAuth() 호출로 반환되는 handlers
, signIn
, signOut
, auth
, update
를 프로젝트에서 사용할 수 있다.
handlers
: 프로젝트의 인증 관리를 위한 API 라우트(GET, POST) 객체signIn
: 사용자 로그인을 시도하는 비동기 함수signOut
: 사용자 로그아웃을 시도하는 비동기 함수auth
: 세션 정보를 반환하는 비동기 함수unstable_update(update)
: 세션 정보를 갱신하는 비동기 함수next-auth의 베타버전에서는
unstable_update
함수는 실험적인 기능으로, 추후 변경될 수 있다.
providers
: Credentials, Google, GitHub, Kakao 등 인증 공급자 지정session
: 세션 관리 방식 지정pages
: 사용자 정의 페이지 경로, 로그인 페이지의 기본값은 /auth/signin
callbacks
: 인증 및 세션 관리 중 호출되는 각 핸들러 지정callbacks.signIn
: 사용자 로그인을 시도했을 때 호출되며, true
를 반환하면 로그인 성공, false
를 반환하면 로그인 실패로 처리callbacks.redirect
: 페이지 이동 시 호출되며, 반환하는 값은 리다이렉션될 URLcallbacks.jwt
: JWT가 생성되거나 업데이트될 때 호출되며, 반환하는 값은 암호화되어 쿠키에 저장callbacks.session
: jwt
콜백이 반환하는 token을 받아, 세션이 확인될 때마다 호출redirect는 꼭 넣어야 하는 값은 아니고, 클라이언트 측에서 router를 사용하여 페이지를 이동시킬 수도 있다.
나는 로그인 이후 로직을 위해 추가해주었다.
import 'next-auth';
import '@auth/core/jwt';
export declare module "next-auth" {
interface User {
accessToken: string;
authProvider: string;
name: string;
email: string
}
interface Session {
user: {
accessToken: string;
authProvider: string;
}
}
}
export declare module '@auth/core/jwt' {
interface JWT {
email: string | null;
accessToken: string;
authProvider: string;
}
}
나는 세션에 사용자 정보도 같이 저장을 하려고 하는데, 해당 정보가 auth에서 제공하는 값 외의 값도 필요해 타입을 추가해주었다.
만약 name, email 같은 기본적인 것만 저장하고자 한다면 타입을 추가해주지 않아도 된다.
나는 accessToken
, authProvider
가 추가로 필요해 설정해주었다.
이렇게 types 폴더에 파일을 추가해 설정만 해주면, auth에서 알아서 타입을 추론한다.
프로젝트의 각 서버 및 클라이언트 컴포넌트에서 사용할 수 있도록, 로그인/회원가입 과 로그아웃을 서버 액션으로 작성한다.
나는 서버 액션으로 작성해도 몇몇 파일에서 에러가 뜨면서 실행이 안돼,
access token
을 불러오는 로직을 추가해주었다.
// src/serverActions/auth.ts
'use server';
import { auth, signOut, update } from '@/auth';
export const signInWithCredentials = async (tempToken: string) => {
if (tempToken) {
// temp-token으로 백엔드에서 access token 요청
const tokenResponse = await fetch(
`${process.env.NEXT_BASE_URL}/서버에게 access token을 요청하는 엔드포인트?temp-token=${tempToken}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
},
);
const tokenResult = await tokenResponse.json();
// 로그인 실패시 access token이 안담기며, 에러가 아닌 실패 응답 반환
if (!tokenResult.accessToken) {
return tokenResult;
}
const result = {
success: true,
accessToken: tokenResult.accessToken,
};
return result;
}
// 실패 응답, 성공 응답 모두에 해당되지 않을시(!tokenResponse.ok) 반환
return {
success: false,
message: 'failed to login',
};
};
// 로그아웃
export const signOutWithCredentials = async () => {
await signOut();
};
// session 호출
export const getSession = async () => {
return auth();
};
// 세션 업데이트
export const updateSession = async (session: any) => {
await update(session);
};
특정 경로로 이동하기 전에, 서버 측에서 실행되는 코드(미들웨어)를 제공해 경로별 인증 여부를 확인할 수 있다.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from './serverActions/auth';
export async function middleware(request: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.redirect(`${process.env.NEXT_CLIENT_URL}/`);
}
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
export const config = {
matcher: ['/agoras', '/flow/:path*', '/messages', '/create-agora'],
}
matcher에 위의 인증 여부를 확인하고자 하는 경로를 추가하면 된다.
특정 경로의 하위 경로까지 모두 검사하고 싶다면, /flow/:path*
처럼 /:path*
를 작성해주면 된다.
나는 회원가입 페이지를 다음과 같이 작성해주었다.
import React from 'react';
import Link from 'next/link';
export default function SNSLogin() {
const getRedirectUri = (provider: string) => {
return `${process.env.NEXT_BASE_URL}/서버에게 소셜 로그인 요청하는 경로`;
};
return (
<>
<Link
href={getRedirectUri('kakao')}
aria-label="카카오로 로그인하기"
className="text-sm relative flex justify-center items-center w-full bg-[#FEE500] border-1 border-[#FEE500] rounded-md h-42 p-12"
>
<div className="absolute left-12" aria-hidden>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
className="w-24 h-24"
>
<path
d="M12.5482 4C7.83195 4 4 7.1 4 10.9C4 13.3 5.57208 15.4 7.83195 16.7L7.24242 20L10.8779 17.6C11.3691 17.7 11.9587 17.7 12.4499 17.7C17.1662 17.7 20.9981 14.6 20.9981 10.8C21.0964 7.1 17.2645 4 12.5482 4Z"
fill="black"
/>
</svg>
</div>
<div className="opacity-85 flex-1 justify-center items-center text-center">
카카오 로그인
</div>
</Link>
<Link
href={getRedirectUri('google')}
aria-label="구글로 로그인하기"
className="text-sm relative flex justify-center items-center w-full bg-white border-1 border-dark-line-semilight rounded-md h-42 p-12"
>
<div className="absolute left-12" aria-hidden>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-19 h-19"
viewBox="-3 0 262 262"
preserveAspectRatio="xMidYMid"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
</div>
<div className="opacity-85 flex-1 justify-center items-center text-center">
구글 로그인
</div>
</Link>
</>
);
}
svg는 아이콘인데, 어차피 이곳에서만 사용될 아이콘이라 컴포넌트로 만들어주지 않고 바로 사용했다.
이제 저 Link
태그를 클릭하면, 서버에게 전달한 redirect uri 경로로 이동하게 된다. (이건 백엔드와 상의하여 결정)
내 경우엔 /login 페이지로 이동하도록 하였다.
/login 경로로 이동하면, route가 실행되도록 했다.
// src/app/(main)/login/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const token = searchParams.get('temp-token');
const error = searchParams.get('error');
if (error) {
return NextResponse.redirect('/');
}
return NextResponse.redirect(`${origin}/login/auth?user=${token}`);
}
서버에서 쿼리 파라미터로 전달하는 temp-token을 추출해 이를 /login/auth 페이지로 이동하면서 전달해준다.
사실, 이 과정을 추가하는게 복잡하고 단계를 더 늘리는거라서 middleware에서 모든걸 처리하려고 했다.
temp-token을 추출해서 access token 발급 ~ 회원정보 받기까지.
하지만, middleware
는 주로 인증 상태를 확인하고, 페이지 접근을 제한하는 역할로 사용된다.
로그인 처리는 API Route
나 auth
의 callbacks
과 같은 서버측 인증 로직에서 수행하는 것이 더 적합하다.
middleware
는 접근 제어에만 사용하고, 로그인 로직은 별도로 처리하는 것이 권장된다.
그래서 페이지를 따로 두고 로그인 로직을 실행하는걸로 결정한 것이다.
본론으로 돌아와서, /login/auth 페이지로 이동하면 로그인 로직이 실행된다.
// src/app/(main)/login/auth/page.tsx
import React from 'react';
import SignIn from '../../_components/templates/SignIn';
import AuthLogin from './components/AuthLogin';
type Props = {
searchParams: {
user: string;
};
};
export default function LoginConfirm({ searchParams }: Props) {
const { user } = searchParams;
return (
<div>
<SignIn />
<AuthLogin user={user}/>
</div>
);
}
// src/app/(main)/login/auth/components/AuthLogin.tsx
'use client';
import Loading from '@/app/_components/atoms/loading';
import showToast from '@/utils/showToast';
import { signInWithCredentials } from '@/serverActions/auth';
import { signIn, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import React, { useEffect } from 'react';
type Props = {
user: string;
}
export default function AuthLogin({user}: Props) {
const router = useRouter();
const session = useSession(); // 클라이언트 컴포넌트에서 사용
const getUserAccessToken = async (authuser: string) => {
const tempToken = await signInWithCredentials(authuser);
// access token이 정상적으로 발급되었다면
if (tempToken.success) {
try {
// auth의 로그인 실행
await signIn('credentials', {
accessToken: tempToken.accessToken,
});
} catch (error) {
showToast('로그인에 실패했습니다. 다시 시도해주세요.', 'error');
router.replace('/');
}
} else if (!tempToken.success) {
showToast('로그인에 실패했습니다. 다시 시도해주세요.', 'error');
router.replace('/');
}
};
useEffect(() => {
if (session.data) {
router.replace('/home');
}
}, [session]);
useEffect(() => {
// 페이지 접근시 로그인 로직 실행
getUserAccessToken(user);
}, []);
return (
<div className="min-w-300 w-full h-full flex absolute justify-center items-center z-20 top-0 right-0 left-0 bottom-0 bg-opacity-50 bg-dark-bg-dark">
<Loading className="absolute z-100" w="32" h="32" />
</div>
)
}
로그인이 정상적으로 끝나면 홈으로 이동하게 되고, 실패하면 로그인 페이지로 이동하게 된다.
useEffect(() => {
if (session.data) {
router.replace('/home');
}
}, [session]);
이 코드는 처음 해당 페이지에 접근할 땐 session이 비어있게 되고, 로그인이 된 후에 채워지게 된다.
그렇기에 session 데이터의 변동이 일어나면 이를 확인하고, 홈으로 이동하게 해주었다.
위 코드를 통해서 혹시나 이미 로그인 한 사용자가 직접 경로를 변경하여 로그인 페이지로 이동하려고 할 때, 홈으로 돌려보낼 수 있다.
// src/app/(main)/page.tsx
export default async function Page() {
const session = await getSession();
if (session?.user) {
redirect('/home');
return null;
}
return <SignIn />;
}
로그인 메인 페이지에서도 이렇게 작성하면 홈으로 돌려보낼 수 있다.
auth를 통한 세션 관리 설정은 끝났다.
여기에 토큰 재발급 로직을 추가하고자 한다면, 만료시 재발급되도록 auth에 로직을 추가하거나
api 호출 후 받는 응답에 따라 재발급 후 세션을 update 시켜주면 된다.
// example
import { updateSession } from '@/serverActions/auth';
await updateSession(result.response);
맥 mov 에서 gif로 바꾸면 엄청 느려지는 매우 맘에 안드는 현상......
참고 문서
https://www.heropy.dev/p/MI1Khc
https://supersett-diary.tistory.com/301