Logo

Prolip's Blog

middleware로 CORS 해결하기
  • #Project
  • #Burgerput

middleware로 CORS 해결하기

Prolip

2024-08-10

Next-Auth로 로그인 기능 구현하기, middleware로 CORS 문제 해결하기

어김없이 시작..

이전 포스팅에선 웹소켓이 뭔지, 어떻게 연결했는지를 다뤘습니다.

이번 포스팅에선 채팅 서버에서 어떻게 로그인 기능을 구현했는지, CORS 문제를 어떻게 해결했는지를 작성해보려고 합니다..

로그인 기능에 대해서 이번에도 고민이 좀 많았습니다.

이건 버거풋과 마찬가지로 단순히 접근 제한을 위해 필요한 기능이었습니다.

그래서 회원 가입이 필요 없었습니다. 즉, 회원 정보가 필요 없어 DB가 필요 없었습니다.

그래서 어떻게 구성했느냐!

아이디랑 비밀번호를 환경 변수로 관리하게 됩니다. github secrets에 등록해 사용하는데 이거 기본적으로 암호화가 되어있기 때문에 제가 어설프게 암호화하느니 여기 등록하는게 좋을 거 같았습니다.

Next-Auth

우선 채팅 서버는 Next.js로 구현했고 로그인 기능에 Next-Auth 라이브러리를 사용했습니다.

해당 라이브러리는 Next.js에서 인증 기능을 매우 매우 매우 쉽게 구현할 수 있게 해주는 라이브러리로 이전에 버거풋 로그인 관련 포스팅에서 정리한 JWT 토큰 기반의 인증을 간단하게 사용할 수 있는 기능을 제공합니다.

특히 Next-Auth는 다양한 인증 제공자를 지원하고 JWT를 기본 옵션으로 활용할 수 있는 장점이 있습니다.

Next-Auth 영업할 겸 장점을 소개해드리자면

1. 간편한 설정

복잡한 인증 로직을 쉽게 구현할 수 있습니다. 여러 개의 인증 제공자를 지원하고 기본적으로 OAuth2, Credentials, 자격 증명 기반 로그인 등을 쉽게 설정할 수 있습니다.

추가적인 백엔드 서버나 DB 설정 없이도 JWT를 활용한 상태 저장을 처리할 수 있습니다.

2. 다양한 인증 제공자 지원

OAuth 제공자인 Google, Facebook, Github, Kakao, Naver 등을 통한 로그인 기능을 쉽게 설정할 수 있습니다.

저는 OAuth 안 쓰고 아이디, 비밀번호로 구현했습니다.

이메일 쓰는 Magic Link 인증도 지원합니다.

3. JWT 기반 인증 지원

Next-Auth는 JWT를 기본적으로 사용해 세션을 관리합니다. JWT 토큰을 클라이언트 측에 저장하고 인증이 필요한 요청에 활용 가능합니다.

JWT 옵션을 사용하면 토큰의 만료 시간을 관리하거나 토큰의 암호화 등을 쉽게 설정할 수 있고 비동기 요청에 대한 인증 처리도 쉽게 처리할 수 있습니다.

토큰 자동 갱신도 지원합니다..

4. 경로에 대한 검증

middleware 쓰면 특정 경로에 대한 페이지 보호도 쉽게 가능합니다.

설명은 간단하게 하고 어떻게 구현했는지 확인해봅시다.

Route.ts (/app/api/auth/[…nextauth]/route.ts)

우선 Next.js가 13버전부터 app 라우터를 지원하니 API Routes 사용법에 따라 차근차근 설정해봅시다..

import { authOptions } from "@/app/lib/auth"; import NextAuth from "next-auth/next"; const handler = NextAuth(authOptions); export { handler as GET, handler as POST };

authOptions (/app/lib/auth.ts)

아 이 파일은 route.ts 파일에 정의하면 안됩니다. 전 따로 lib 폴더를 생성해 auth.ts 파일로 관리하고 있습니다.

이유는 Next.js에서는 route.ts 파일에서 임의의 객체를 내보내는 것이 허용되지 않고 GET, POST, PATCH 등과 같은 이름이 지정된 객체만 내보낼 수 있습니다.

만약 route.ts에서 authOptions를 정의한다면 이는 임의의 객체이기 때문에 빌드에 실패하게 됩니다.

import { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ name: "Credentials", credentials: { id: { label: "Id", type: "text", placeholder: "Please enter your ID..", }, password: { label: "Password", type: "password" }, }, authorize(credentials) { if (!credentials) return null; const { id, password } = credentials; const idMatch = process.env.ADMIN_ID === id; const passwordsMatch = process.env.ADMIN_PASSWORD === password; if (idMatch && passwordsMatch) return { id, password }; throw new Error("입력 정보를 다시 확인해주세요."); }, }), ], };

authOptions는 Next-Auth가 어떻게 인증을 처리할지 설정하는 옵션 객체입니다.

이 객체를 통해 인증 제공자, 세션 관리 방식, 인증 성공 및 실패시 리다이렉트 경로 등을 설정할 수 있습니다.

  • CredentailsProvider(자격 증명 제공자)를 사용해 ID와 비밀번호로 인증을 처리하도록 설정했습니다.
  • authorize 함수에서 입력된 값이 올바른지 검증하게 되고 올바른 경우에 사용자 정보를 반환합니다. 위에서 설명했듯 ID/PW는 env 파일로 관리합니다.
export const authOptions: NextAuthOptions = { providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", }), GithubProvider({ clientId: process.env.GITHUB_CLIENT_ID || "", clientSecret: process.env.GITHUB_CLIENT_SECRET || "", }), NaverProvider({ clientId: process.env.NAVER_CLIENT_ID || "", clientSecret: process.env.NAVER_CLIENT_SECRET || "", }), KakaoProvider({ clientId: process.env.KAKAO_CLIENT_ID || "", clientSecret: process.env.KAKAO_CLIENT_SECRET || "", }), ], callbacks: { async signIn({ user: { id, name, email } }) { if (!name) { return false; } createUser({ id, email: email || "", name: name, userid: email ? email.split("@")[0] : name, }); return true; }, async session({ session, token }) { const user = session?.user; if (user && token.sub) { session.user = { id: token.sub, name: user.name, email: user.email, userid: user.email ? user.email.split("@")[0] : user.name, profileimage: "", }; } return session; }, }, pages: { signIn: "/auth/signin", }, };

위 코드는 제가 OAuth를 사용할 때 작성했던 코드입니다. Google, Github, Naver, Kakao 등을 사용해 소셜 로그인 기능을 쉽게 구현할 수 있습니다.

  • signIn 콜백은 사용자가 로그인할 때 실행되며 이 콜백을 통해 로그인 성공 여부를 제어할 수 있습니다.
    • 해당 코드에서는 사용자 이름이 없으면 로그인을 실패시키고 있는데 사이드로 진행했던 프로젝트에선 name이 꼭 필요해서 이렇게 구현했습니다..
  • session 콜백은 세션 정보가 생성되거나 엑세스 될 때 실행됩니다.

물론 이거 사용하려면 각 사이트에 가서 콜백 Url 등록해줘야 됩니다. 하지만 지금 포스팅에선 사용하지 않으니 넘어가겠습니다..

SignIn (/app/auth/signin/page.tsx)

폴더 베이스 라우팅을 지원하니 해당 로그인 페이지는 baseURL/signin 경로로 접근할 수 있습니다.

"use client"; import { signIn } from "next-auth/react"; import { ChangeEvent, FormEvent, useState } from "react"; import { SiBurgerking } from "react-icons/si"; export default function SignPage() { const [formData, setFormData] = useState({ id: "", password: "", }); const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const { id, value } = e.target; setFormData((prev) => ({ ...prev, [id]: value })); }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); const { id, password } = formData; signIn("credentials", { id, password, callbackUrl: process.env.NEXT_PUBLIC_URL, }); }; return ( <section> <SiBurgerking/> <form onSubmit={handleSubmit} > <input type="text" id="id" value={formData.id} onChange={handleChange} placeholder="아이디를 입력해주세요." autoComplete="off" autoFocus /> <input type="password" id="password" value={formData.password} onChange={handleChange} placeholder="비밀번호를 입력해주세요." /> <button> 로그인 </button> </form> </section> ); }
  • signIn 함수를 봅시다..
    • signIn(”credentials”, {…})를 보면 credentials라는 제공자를 사용해 자격 증명 기반의 로그인을 시도합니다.
    • id, password, callbackUrl이 제공되는데 callbackUrl을 통해 로그인 성공 후 리다이렉트할 Url을 설정할 수 있습니다.

middleware.ts

import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; export async function middleware(req: NextRequest) { const token = await getToken({ req }); if (!token) { return NextResponse.redirect(`${process.env.NEXTAUTH_URL}/auth/signin`); } return NextResponse.next(); } export const config = { matcher: ["/"], };

미들웨어는 Next.js에서 요청과 응답 사이에 실행되는 중간 처리 단계로 볼 수 있습니다.

특정 경로에 대한 요청이 들어올 때 그 요청이 최종적으로 페이지나 API 라우트에 도달하기 전에 미들웨어를 통해 요청을 가로채 처리할 수 있습니다.

미들웨어는 인증, 권한 확인, 데이터 전처리, 리다이렉션 등을 처리하는데 아주 유용합니다..

  • getToken
    • next-auth/jwt에서 제공하는 함수로 JWT 토큰을 가져옵니다. 이 때 요청에서 쿠키나 헤더에 포함된 JWT 토큰을 읽어올 수 있습니다.
    • 토큰이 없다면 로그인 페이지로 리다이렉트합니다.
  • config
    • matcher는 미들웨어가 적용될 경로를 지정합니다. 해당 코드에서는 루트 페이지에 접근하려는 모든 요청에 대해 이 미들웨어가 실행되고 토큰이 없으면 로그인 페이지로 리다이렉트 됩니다.

짜잔

signinpage.png

사실 로그인 기능 만드는건 너무 쉬웠습니다..

CORS..

이전 포스팅에서 사용자가 채팅 서버에 입장하면 api를 통해 서버에 알리고 서버는 관리자에게 메일을 보낸다고 했었죠..?

그 과정에서 일어난 CORS를 어떻게 해결했는지 기록해보려 합니다… 여기가 좀 힘겨웠습니다..

우선 사용자가 입장했을 때 메일을 어떤 방식으로 보내고 있을까요??

// api route import { sendMail } from "@/app/services/mail"; import { NextRequest, NextResponse } from "next/server"; export async function POST(req: NextRequest) { const { userName } = await req.json(); if (!userName) { return new Response("Bad Request!", { status: 400 }); } return sendMail(userName) // .then( () => new NextResponse( JSON.stringify({ message: "알림 전송에 성공했어요!" }), { status: 200 } ) ) .catch((error) => { console.error(error); return new NextResponse( JSON.stringify({ message: "알림 전송에 실패했어요!" }), { status: 500 } ); }); } // sendMail import nodemailer from "nodemailer"; import { htmlToText } from "nodemailer-html-to-text"; export async function sendMail(message: string) { const { NAVER_EMAIL, NAVER_PASS, GMAIL_ID } = process.env; const transporter = nodemailer.createTransport({ service: "naver", host: "smtp.naver.com", port: 587, auth: { user: NAVER_EMAIL, pass: NAVER_PASS }, }); transporter.use("compile", htmlToText()); const mailOptions = { from: NAVER_EMAIL, to: GMAIL_ID, subject: `Burgerput에서 문의가 도착했어요!`, html: ` <h1>Burgerput에서 문의가 도착했어요!</h1> <p>${message}</p> <br/> <p>서둘러 접속하세요!</p> <a href="버거챗 URL">접속</a> `, }; await transporter.sendMail(mailOptions); }

해당 경로에 api 요청이 들어오면 sendMail 함수를 사용해 메일을 보냅니다.

위 코드에 대한 설명은 따로 하지 않겠습니다.. 제 블로그에 해당 내용을 정리한 글이 있으니 참고하셔도 좋습니다..

우선 CORS가 뭔지부터 알아봅시다.

CORS가 뭡니까?

CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 보안상의 이유로 서로 다른 도메인 간에 HTTP 요청을 제어하기 위한 정책입니다.

일반적으로 웹 브라우저는 동일 출처 정책(Same-Origin Policy)에 따라 다른 도메인, 프로토콜, 혹은 포트에서 리소스를 요청하는 것을 제한합니다.

이는 웹 사이트에서 브라우저로 실행되는 스크립트가 임의로 다른 도메인의 자원에 접근하거나 중요한 데이터를 탈취하는 것을 방지하기 위함입니다.

1. 동일 출처 정책 (Same-Origin Policy)

브라우저가 보안상 동일한 출처에서 제공된 리소스에만 접근을 허용하는 정책입니다.

// 동일 출처 Domain A : https://www.gojimin.com Domail B : https://www.gojimin.com // 프로토콜 다름 Domain C : https://www.gojimin.com Domain D : http://www.gojimin.com // 도메인 다름 Domain E : https://www.gojimin.com Domain F : https://api.gojimin.com

위의 예시로 보면 도메인 A,B를 제외한 나머지는 리소스 요청이 불가합니다.

2. CORS (Cross-Origin Resource Sharing)

CORS는 위 제한을 완화하기 위한 방법으로 서버가 적절한 HTTP 헤더를 사용해 특정 출처에서의 요청을 허용할 수 있습니다.

즉 서버가 명시적으로 특정 도메인에서 오는 요청을 허용하면 브라우저는 해당 요청을 허용하게 됩니다.

동작 방식

1. HTTP 요청 전송

브라우저는 클라이언트에서 발생한 HTTP 요청을 서버로 보내기 전에 그 요청이 다른 출처의 서버로 보내지는지 확인합니다. 이 과정에서 CORS 정책이 적용됩니다.

위에서 설명했듯 클라이언트는 기본적으로 동일한 출처에서 온 리소스에만 접근할 수 있습니다.

만약 다른 출처로 요청을 보내야 한다면 브라우저는 CORS 정책을 적용하는데 이 요청이 CORS 정책에 따라 허용될지 결정하는 과정에서 Origin 헤더와 여러 HTTP 헤더가 사용됩니다.

2. Origin 헤더 추가

브라우저는 요청에 Origin 헤더를 추가합니다. 이 헤더에는 요청을 보낸 웹 페이지의 출처가 포함됩니다.

3. 서버의 CORS 헤더 검사

서버는 클라이언트로부터 받은 요청에서 Origin 헤더를 확인해 해당 요청을 허용할지 여부를 결정합니다. 서버는 요청한 출처를 허용할 경우 응답에 필요한 CORS 헤더를 추가합니다.

  • Access-Control-Allow-Origin: 서버가 요청을 허용할 출처를 명시합니다. 이를 통해 브라우저가 서버에서 해당 요청을 허용했는지 여부를 결정합니다.

이후 서버는 응답에 CORS 관련 헤더들을 추가할 수 있습니다.

  • Access-Control-Allow-Methods: 서버가 허용하는 HTTP 메서드를 명시합니다.
  • Access-Control-Allow-Headers: 요청에 사용할 수 있는 커스텀 헤더들을 명시합니다.
  • Access-Control-Allow_Credentials: 브라우저가 요청에 쿠키, 인증 헤더 등을 포함할 수 있는지 여부를 표시합니다.
  • Access-Control-Max-Age: 브라우저가 preflight 요청에 대한 결과를 캐시할 수 있는 시간을 초 단위로 명시합니다.

4. 브라우저의 처리

이제 서버의 응답을 받은 브라우저는 CORS 관련 응답 헤더를 확인합니다. 이 헤더들을 통해 브라우저는 요청이 허용되었는지 판단하고 요청이 허용된 경우에만 실제로 요청을 처리하게 됩니다.

만약 서버가 적절한 CORS 헤더를 포함해 응답을 반환하면 브라우저는 요청을 정상적으로 처리하게 됩니다.

혹은 서버가 Access-Control-Allow-Origin 헤더를 포함하지 않았거나 허용되지 않은 출처일 경우 브라우저는 요청을 차단하고 콘솔에 CORS 에러를 출력하게 됩니다.

Preflight

예비 요청(Preflight)은 CORS 정책 하에서 특정 조건을 만족하는 HTTP 요청을 하기 전 브라우저가 서버에 사전 확인 요청을 보낼 수 있습니다.

브라우저는 서버에 요청을 보내기 전에 해당 요청이 안전한지 그리고 서버가 허용할 의향이 있는지 확인할 수 있습니다.

이 과정은 OPTIONS 메서드를 사용해 이루어지며 브라우저가 서버의 응답에 따라 실제로 요청을 보낼지 결정하게 됩니다.

그럼 언제 Preflight 요청이 발생할까요?

Simple Requests가 아니라고 판단되는 경우에 발생한다고 합니다.

이 Simple Requests의 조건은 다음과 같습니다.

  1. GET, HEAD, POST 중 하나여야 함.
  2. CORS 규격에서 허용하는 기본 헤더만 포함되어야 함
  • Accept, Accept-Language, Content-Language, Content-Type(이 때 값은 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나)

그럼 즉 다음과 같은 상황에선 Preflight 요청이 발생하게 됩니다.

  1. PUT, DELETE, PATCH 등은 안전하지 않은 메서드로 이 요청이 보안적으로 민감한 작업인 데이터 변경 혹은 삭제 등을 수행할 가능성이 크다고 판단합니다.
  2. 요청에 일반적인 HTTP 헤더 외 커스텀 헤더가 포함된 경우 브라우저는 이 헤더가 서버에서 허용되는지 사전에 확인해야됩니다.
  3. Content-Type이 application/json 등과 같이 커스텀 미디어 타입을 사용한다면 Preflight 요청이 발생합니다.

Preflight의 동작 과정은 다음과 같습니다.

1. OPTIONS 메서드를 통한 사전 확인: 브라우저는 실제 요청을 보내기 전에 먼저 OPTIONS 메서드를 사용해 Preflight 요청을 서버로 보냅니다. 이 요청에는 서버가 클라이언트의 실제 요청을 허용할지 판단할 수 있는 정보를 포함합니다.

  • Origin: 요청을 보낸 도메인 출처
  • Access-Control-Request-Method: 실제 요청에 사용할 HTTP 메서드를 명시합니다.
  • Access-Control-Request-Headers: 실제 요청에 사용할 커스텀 헤더를 명시합니다.

2. 서버의 응답: 서버는 이 Preflight 요청을 받아 다음과 같은 헤더를 포함해 응답합니다.

  • Access-Control-Allow-Origin: 허용된 출처를 명시합니다.
  • Access-Control-Allow-Methods: 허용된 HTTP 메서드를 명시합니다.
  • Access-Control-Allow-Headers: 허용된 커스텀 헤더를 명시합니다.
  • Access-Control-Max-Age: 이 응답을 캐시할 수 있는 시간을 초 단위로 명시합니다. 브라우저는 이 시간을 기준으로 동일한 요청에 대해 Preflight 요청을 다시 보내지 않고 캐시된 응답을 사용할 수 있습니다.

3. 실제 요청 전송: 브라우저는 서버가 보낸 응답을 확인해 요청이 확인되었을 경우 실제 요청을 전송합니다. 만약 서버가 허용하지 않는 경우엔 브라우저는 실제 요청을 보내지 않고 CORS 에러를 발생시킵니다.

다시 미들웨어

네.. 결국 버거풋과 버거챗은 도메인이 달라 동일 출처 정책(Same-Origin Policy)을 위반해 CORS 문제가 발생했던 것이었습니다.

그럼 채팅 서버의 허용된 출처 목록에 버거풋 도메인을 등록하고

요청이 들어올 때 origin 헤더를 가져와 서버의 허용 목록에 들어있다면 Access-Control-Allow-Origin 헤더를 설정해 해당 도메인의 요청을 허용하면 되겠군요?

import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; const allowedOrigins = [ process.env.BURGERPUT_SITE_1, process.env.BURGERPUT_SITE_2, ]; const corsOptions = { "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; export async function middleware(req: NextRequest) { if (req.nextUrl.pathname === "/") { const token = await getToken({ req }); if (!token) { return NextResponse.redirect(`${process.env.NEXTAUTH_URL}/auth/signin`); } return NextResponse.next(); } const origin = req.headers.get("origin") ?? ""; const isAllowedOrigin = allowedOrigins.includes(origin); const isPreflight = req.method === "OPTIONS"; if (isPreflight) { const preflightHeaders = { ...(isAllowedOrigin && { "Access-Control-Allow-Origin": origin }), ...corsOptions, }; return NextResponse.json({}, { headers: preflightHeaders }); } const res = NextResponse.next(); if (isAllowedOrigin) { res.headers.set("Access-Control-Allow-Origin", origin); } Object.entries(corsOptions).forEach(([key, value]) => { res.headers.set(key, value); }); return res; }

이렇게 CORS 문제를 해결할 수 있었습니다..

마치며..

사실 그렇게 어렵진 않았습니다.. 필요한 기능이 그렇게 많지 않기도 했구요.

근데 Vercel에 배포해서 날로 먹으려던게 실패해서 그건 좀 아쉬웠습니다.

.

.

.

뿅..

웹소켓으로 통신해보기..

웹소켓으로 통신해보기..

채팅 서버 배포 파이프라인 구성하기

채팅 서버 배포 파이프라인 구성하기