프로젝트에서 소셜로그인을 구현하자는 얘기가 나왔고, 우선 카카오 로그인을 구현하기로 정했다.
먼저 Kakao developers 에 어플리케이션을 추가하자.
그 후 사이드바의 앱 키 탭을 눌러보면
위와 같이 4개의 키를 얻을 수 있다.
필수로 사용해야 할 키는 REST API 키와 JavaScript 키이다.
해당 값들을 환경변수로 다음과 같이 등록해놓자.
NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY='Your JAVASCRIPT KEY'
NEXT_PUBLIC_KAKAO_REST_API_KEY='Your REST API KEY
만약 추가 보안을 원한다면 보안 탭에서 Client Secret를 사용한다 (여기서는 일단 패스) .
다음으로 플랫폼 탭으로 이동 후
위에서 사용할 사이트 도메인과, Redirect URI를 등록해야한다.
사이트 도메인에는 로컬에서 테스트 할 주소와 실제 배포할 주소를 적어주자.
그 후 Redirect URI 등록하러 가기를 클릭한다.
마찬가지로 로컬, 배포주소에 대한 Redirect URI를 모두 적어준다.
Redirect URI는 카카오 로그인 하기 버튼을 누르면 이동하는 페이지 이므로 클라이언트 url을 작성해야한다.
마지막으로 카카오 로그인 탭에서
위처럼 활성화 설정을 ON으로 바꾸면 모든 준비가 끝났다.
카카오 Javascript SDK 파일 포함시키기
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<Script
type="text/javascript"
src={`https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID}&submodules=geocoder`}
></Script>
<KakaoScript />
<body>{children}</body>
</html>
);
}
위는 가장 바깥에 있는 layout.tsx 파일이다.
declare global {
interface Window {
Kakao: any;
}
}
function KakaoScript() {
const onLoad = () => {
window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY);
};
return (
<Script
src="https://developers.kakao.com/sdk/js/kakao.js"
async
onLoad={onLoad}
></Script>
);
}
KakaoScript의 내용이다.
window.Kakao에 접근할 수 있도록 global로 선언해주었다.
next/script를 이용하여 src 속성에 sdk파일 주소를 적어준 뒤, onLoad속성을 통해 초기화 함수를 호출해주었다. 이 때 JavaScript 키가 사용된다.
Redirect URI 에서 인가코드 받기
export default function Login() {
return (
...
<div className="w-full px-[2rem]">
<KakaoButton />
<LinkLayout routeUrl="/login/email">
<Button variant="line" className="w-full">
이메일로 시작하기
</Button>
</LinkLayout>
</div>
...
</div>
);
}
위는 로그인 페이지의 일부이다. 카카오 버튼을 클라이언트 컴포넌트로 따로 생성해주었다.
"use client";
import KakaoIcon from "@common/assets/icons/kakao/KakaoIcon";
import Button from "../Button/Button";
export default function KakaoButton() {
const protocol = process?.env.NODE_ENV === "development" ? "http" : "https";
const host =
process?.env.NODE_ENV === "development"
? "localhost:3000"
: "localmood.co.kr";
const redirectUri = `${protocol}://${host}/login/kakao`;
const kakaoLogin = () => {
location.href = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY}&redirect_uri=${redirectUri}`;
};
return (
<Button
className="relative flex w-full justify-center items-center mb-[1.2rem] bg-kakao"
onClick={kakaoLogin}
>
<KakaoIcon className="absolute left-[1.2rem]" />
<span className="body2-semibold text-black">카카오로 시작하기</span>
</Button>
);
}
카카오 버튼의 코드는 다음과 같다.
process?.env.NODE_ENV 값을 통해 개발과 배포 환경을 구분하여 redirectUri를 설정해주었다.
카카오로 시작하기 버튼을 누르면 지정한 주소로 이동하게 설정하였다.
이 때 쿼리 파라미터의 client_id 에는 위에서 지정해 준 REST API 키가 들어가야 하고, redirect_uri 에는 개발&배포 환경을 구분하여 설정해준 주소 값을 넣어주었다.
카카오로 시작하기 버튼을 누르면 어디로 이동하는지 봐보자.
위에서 설정한 Redirect URI인 /login/kakao 경로로 이동한 것을 확인할 수 있다.
그런데 쿼리 파라미터로 code 값이 같이 전달된 것을 확인할 수 있다. 이거는 어디에 쓰는 것일까?
이 code를 카카오에게 토큰을 받을 수 있는 경로로 전달한 후, 카카오 측에서 해당 code를 확인한 뒤 토큰을 내려주게 된다. 토큰을 성공적으로 받으면 로그인에 성공한 것이다.
나같은 경우 프로젝트의 여러 api에서 우리 프로젝트 백엔드에서 직접 만든 토큰들을 사용해야 했다.
따라서 우리가 채택한 로그인 로직은 다음과 같다.
- 프론트에서 code값을 추출한 뒤, 토큰을 얻기 위한 GET api의 쿼리 파라미터로 백엔드에 전달
- 백에서 해당 code값을 받은 뒤 카카오에 토큰 요청
- 카카오에서 토큰을 받으면 해당 토큰을 가지고 우리 프로젝트 자체의 accessToken과 refreshToken 생성
- 해당 토큰들을 결과 값으로 반환해줌
백엔드로 인가코드 넘기기
"use client";
import { useEffect } from "react";
export default function KakaoRedirect({
searchParams,
}: {
searchParams: { code: string };
}) {
const { code } = searchParams;
useEffect(() => {
const getAuthorization = async () => {
const res = await fetch(`/api/auth/login/kakao?code=${code}`);
if (res.status === 200) {
location.replace("/");
}
if (res.status === 400) {
alert("로그인 과정에서 문제가 발생했습니다");
}
};
getAuthorization();
}, [code]);
return <div>로그인 중입니다...</div>;
}
위는 Redirect URI 경로의 페이지 코드이다.
해당 페이지로 접근 시, 로그인 로직을 수행 할 Route Handler를 code 쿼리 파라미터를 추가하여 호출한다.
code는 searchParams를 활용하여 추출하였다.
import { encryptData } from "@feature/auth/utils/encryptData";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const res = await fetch(
`${
process.env.NEXT_PUBLIC_SERVER_API
}/api/v1/auth/kakao/login?code=${searchParams.get("code")}`,
{
headers: {
"Content-Type": "application/json",
},
}
);
...
}
다음은 /api/auth/login/kakao 경로에 있는 route.ts 파일에 정의된 코드이다.
위에서 route.ts 파일 경로를 fetch할 때 쿼리 파라미터로 전달했던 code를 Web Request API를 확장한 NextRequest를 통해 추출할 수 있다.
한번 더 추출한 code를 이제 진짜 백엔드한테 전달해주었다.
백엔드에서 만든 accessToken, refreshToken 쿠키에 저장하기
if (res.ok) {
const data = await res.json();
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const auth_session = await encryptData({ data, expires });
cookies().set("auth_session", auth_session, { expires, httpOnly: true });
return NextResponse.json("Success", { status: 200 });
}
return NextResponse.json("Error", { status: 400 });
통신이 성공적으로 완료(res.ok)되면 이제 받아온 결과(토큰들)를 쿠키에 저장해야 한다.
위 코드는 그 위의 백엔드와 통신하는 코드에서 ... 에 해당하는 코드이다.
쿠키는 서버 사이드에서만 설정이 가능하므로 서버 액션, 혹은 Route Handler를 사용해야 한다.
cookies().set 메소드를 통해 쿠키를 설정해 주었다.
나는 쿠키의 만료 시간을 일주일로 정해주었고, httpOnly: true 를 통해 브라우저에서 쿠키에 접근할 수 없도록 설정하였다(XSS 공격 방지). CSRF 와 XSS 공격에 대해서는 뒤에서 알아보도록 하자.
그런데 백에서 얻어온 토큰들을 그대로 쿠키에 저장시키면 브라우저의 개발자 도구만 들어가면 해당 값을 바로 확인할 수 있다. 이는 딱봐도 보안에 좋은 방법이 아니다.
jose 라이브러리로 데이터 암호화하기
나는 encryptData 함수를 하나 생성하였다.
import { JWTPayload, SignJWT } from "jose";
const key = new TextEncoder().encode(process.env.NEXTAUTH_SECRET);
export const encryptData = async (payload: JWTPayload) => {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1 week from now")
.sign(key);
};
SignJWT 클래스를 살짝만 들여다보자.
export declare class ProduceJWT {
protected _payload: JWTPayload;
/** @param payload The JWT Claims Set object. Defaults to an empty object. */
constructor(payload?: JWTPayload);
...
setExpirationTime(input: number | string | Date): this;
...
setIssuedAt(input?: number | string | Date): this;
}
export declare class SignJWT extends ProduceJWT {
private _protectedHeader;
...
setProtectedHeader(protectedHeader: JWTHeaderParameters): this;
...
sign(key: KeyLike | Uint8Array, options?: SignOptions): Promise<string>;
}
위와 같이 SignJWT 클래스는 ProduceJWT 클래스를 확장하여 정의된 클래스이다.
클래스에 정의되어 있는 여러 메소드가 있지만 내가 사용한 메소드만 남겨놓았다.
JWTPayload 의 interface를 간단하게만 들여다보자.
export interface JWTPayload {
...
exp?: number
...
iat?: number
/** Any other JWT Claim Set member. */
[propName: string]: unknown
}
위와 같이 객체의 형태인데 특정 요소들이 option 형태로 정의되어 있고, key와 value 쌍의 아무 데이터도 추가 가능하다.
setExpirationTime 메소드로 JWTPayload의 exp 요소를, setIssuedAt 메소드로 iat 요소를 설정해준 뒤, setProtectedHeader 메소드와 키값을 넣어야하는 sign메소드를 통해 JWT 생성에 보안적인 요소를 추가해주었다.
sign 메소드에 사용할 key 값은 임의로 정해 환경변수로 설정해준 뒤, Uint8Array 형식에 맞게 자바스크립트에서 제공하는 TextEncoder객체를 활용하였다.
결국, 우리가 payload로 넘긴 토큰 데이터들이 exp, iat 속성들이 들어간 JWT로 변환되게 되는것이다!
이제 로그인을 실행할 모든 준비를 끝났다.
로그인 버튼을 눌러 얻어오는 데이터와 설정되는 쿠키를 확인해보자.
위와 같이 로그인이 성공하면 홈 화면으로 redirect 되도록 설정하였다.
로그인을 실행할 시 암호화되는 payload를 콘솔에 찍어보았다.
위와 같은 형식으로 데이터들이 잘 받아와 지는 것을 확인할 수 있다(총 암호화 되는 데이터들은 위에서 exp, iat 속성들이 추가된 데이터들).
다음으로 개발자 도구에서 설정한 이름의 토큰을 확인해보자.
위와 같이 전혀 알아볼 수 없는 암호화 된 값으로 쿠키가 설정된 것을 확인할 수 있다!!
이제 토큰들을 활용하여 api의 Authorization Header에 활용하거나 middleware 등의 파일에서 사용해야 한다.
암호화한 토큰 가져다 사용하기
"use server";
import { decryptData } from "@feature/auth/utils/decryptData";
import { JWTPayload } from "jose";
import { cookies } from "next/headers";
export interface CustomJWTPayload extends JWTPayload {
data?: {
accessToken: string;
refreshToken: string;
};
}
export const getSession = async (): Promise<CustomJWTPayload | null> => {
const encryptedSession = cookies().get("auth_session")?.value;
return encryptedSession ? await decryptData(encryptedSession) : null;
};
위 코드는 암호화한 토큰 정보를 원래 object 형태로 가져오기 위해 만든 getSession 함수 내의 코드이다.
import { jwtVerify } from "jose";
const key = new TextEncoder().encode(process.env.NEXTAUTH_SECRET);
export const decryptData = async (encoded_data: string) => {
const { payload } = await jwtVerify(encoded_data, key, {
algorithms: ["HS256"],
});
return payload;
};
다음은 복호화를 진행하기 위해 만든 decryptData 함수이다.
jose 라이브러리에서 제공하는 jwtVerfiy 메소드에 암호화된 데이터, 설정해준 키값, 그리고 암호화할 때 setProtectedHeader 메소드에서 설정했던 알고리즘을 적어주면 복호화해서 사용할 수 있다.
export async function GET(request: NextRequest) {
const auth_info = await getSession();
const token = auth_info?.data?.accessToken;
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_API}/api/v1/curation/member`,
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
...
위 코드는 accessToken을 Authorization Header에 넣어야 하는 api중 하나이다.
위에서 만들어준 데이터를 다시 복호화하는 getSession 함수가 return해주는 데이터를 콘솔에 찍어보았다.
위와 같이 다시 데이터들이 object 형태로 잘 출력되는 것을 확인할 수 있다!!
이제 각 속성에 접근해서 사용할 곳에서 마음껏 사용하면 된다.
middleware 에서 특정 경로 접근 미리 제한하기
프로젝트의 루트 경로에 middleware.ts 파일을 생성하면 어떤 특정한 경로로의 접근을 제한할 수 있다.
예를 들어, 꼭 로그인을 해야만 하는 마이페이지에 로그인을 안한 상태로 접근 시 로그인 페이지로 이동시켜주거나, 로그인 된 상태인데 로그인 페이지로 다시 접근하려 할 때 이를 막아 줄 수 있는 것이다.
import { getSession } from "@common/utils/session/getSession";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const loggedIn = await getSession();
if (!loggedIn?.data?.accessToken) {
return NextResponse.redirect(new URL("/login", request.url));
}
return;
}
export const config = {
matcher: [
"/record",
"/record/:path*",
"/mypage",
"/curation",
"/curation/:path*",
],
};
위는 middleware.ts 파일의 예시이다.
config의 matcher 속성에 제한하고 싶은 경로를 설정해 주면 된다.
위에서 만든 getSession 함수를 이용하여 만약 accessToken이 없다면 로그인 페이지로 이동시켜주었다.
이제 로그인을 하지 않은 상태로 마이페이지에 접근하려는 시도를 해보자.
위처럼 마이페이지에 가는 대신 로그인 페이지로 이동되는 것을 확인할 수 있다!
로그아웃 처리하기
import { getSession } from "@common/utils/session/getSession";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST() {
const auth_info = await getSession();
const token = auth_info?.data?.accessToken;
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_API}/api/v1/auth/logout`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
if (res.ok) {
cookies().delete("auth_session");
return NextResponse.json({ success: "Logout Successful" }, { status: 200 });
}
return NextResponse.json({ error: "Logout Failed" }, { status: 400 });
}
나는 현재 저장되어있는 쿠키 값을 지워주는 방식으로 처리하였다.
즉, 백엔드에게 로그아웃 한다고 알려주는 api를 호출한 뒤, cookies().delete 메소드를 통해 쿠키 값만 삭제해주면 로그아웃이 완료된다.
보안에 대한 고민
로그인 기능을 구현하며 당연히 보안에 대해 매우 많은 고민을 거쳤다.
XSS 공격
XSS 공격은 공격자(해커)가 클라이언트 브라우저에 Javascript를 삽입해 실행하는 공격이다.
Javascript를 삽입하여 글로벌 변수 값을 가져오거나, 그 값을 이용하여 해커의 코드가 마치 내 사이트의 로직인 척 위장할 수 있다는 뜻이다.
CSRF 공격
CSRF 공격은 공격자(해커)가 다른 사이트에서 우리 사이트의 API 콜을 요청해 실행하는 공격이다.
이 때, access token 같은 값을 클라이언트에서 탈취할 수 있다면 api 콜에 성공하게 되므로 외부인이 마치 유저인 척 행동할 수 있다는 뜻이다.
우선 나는 쿠키에 httpOnly 속성을 true로 설정해 줌으로써 XSS 공격을 방지하였다. httpOnly 속성이 true 면 자바스크립트로 해당 쿠키 값에 접근할 수 없기 때문이다.
또한 쿠키는 자동으로 서버에 전송되지만, 나는 암호화 된 토큰 값을 따로 복호화 한 후 사용하므로 쿠키가 자동으로 전달된다고 해도 해당 값을 서버에서 직접적으로 사용하는 일이 없으므로 CSRF 공격에도 안전하다고 판단하였다.
또 암호화 된 토큰 정보로 인해 브라우저가 노출된다고 해도, 키 값이 없으면 이를 복호화 할 수도 없다.
비록 아직 완전한 보안은 힘들겠지만, 당장은 채택한 방식이 최선이라고 판단하였다.
앞으로 더 공부를 통해 더욱 완전한 보안법이 있는지 알아봐야겠다!
추가해야할 내용
refresh token을 이용한 silent refresh를 구현 예정중에 있다.
만약에 access token을 탈취당한다면 이를 탈취한 사람이 계속 사용하면 안되므로, refresh token을 통해 access token을 재발급 해주어야한다.
현재는 쿠키에 설정한 expiration Date 를 통해 해당 기간이 지나면 쿠키가 자동 삭제된다. 만약 유효 기간이 지난 후 특정 api 및 페이지에 접근 시, 로그인이 필요하다면 다시 로그인을 거쳐야 한다.
이 때 카카오에서 받아오는 인가 코드 값이 달라지고, 달라진 코드 값이 전달되면 우리의 백엔드에서는 다른 토큰 값들을 전달해주고 있다.
위처럼 설정해도 큰 문제는 없지만, 보안이 엄청 민감한 사이트가 아님에도 사용자들은 최대 일주일마다 무조건 재로그인을 해야한다는 문제점이있다.
뭐 그렇게 큰 작업은 아니겠지만, silent refresh 를 통해 유효 기간이 얼마 남지 않았을 때, 자동으로 api 호출을 통해 토큰 값들을 갱신하고, 쿠키에 다시 저장해 준다면 더 효율적인 서비스가 될 것 같다.
더 추가 공부를 통해 보완해야할 점이 있다면 보완해야겠다~!!
'FrontEnd > Next' 카테고리의 다른 글
Next 성능 개선 일지 2 (Feat: FOUT, bundle-analyzer, Link prefetch) (0) | 2024.05.04 |
---|---|
Next 성능 개선 일지 1 (Feat: Lighthouse, Next/Image) (2) | 2024.04.18 |
Next 에서 데이터 관리하기 (Feat : Cache) (2) | 2024.03.09 |
Next + TailwindCSS 세팅하기 (2) | 2023.12.28 |