전편의 Next.js 쿠키와 JWT 토큰으로 로그인 구현에서 이어집니다.
Next.js 쿠키와 JWT 토큰으로 로그인 구현
Next.js로 웹 앱의 인증 기능을 구현하면서 MongoDB, Next API Routes와 Next Auth라는 Hooks로 인증 상태관리가 편리한 라이브러리를 사용했습니다. 하지만 이것만으로는 모든 상황의 인증 로직 구현을 이해
ekard.tistory.com
요즘 웹 사이트나 앱, 게임에서 로그인을 하면 대부분 구글, 애플, 카카오톡, 네이버 같은 편리한 소셜 로그인이 있습니다. 개발자들은 이런 편리한 로그인들을 OAuth 2.0으로 구현한다는 것을 얼핏 들어보신 적이 있을겁니다.
오늘은 OAuth 2.0에 대한 핵심 개념만 짚어보고 저번 글에서 구현한 Next.js 웹 앱에도 적용해보려 합니다.
OAuth 2.0
권한을 위한 업계 표준 프로토콜입니다. 인증이 아니라 권한이라고 명시되있으며 OAuth 2.0을 사용하면 Access Token을 발급해주어서 사용자에게 접근(Access) 할 수 있는 권한을 부여하는 것이 궁극적인 목표입니다. 웹 앱, 데스크톱 앱, 모바일, 가전에 대해 특정한 권한 부여 흐름(specific authorization flows)을 제공합니다. 빅테크 기업들이 밀어준 뒤 전 세계에서 표준으로 자리잡았습니다.
Bearer
보통 Axios로 인증 요청을 보내실 때, { Authorizaion: "Bearer " + token } 과 같은 코드를 HTTP request header에 넣는 코드를 많이 보신 경험이 있으실겁니다. Bearer란 RFC 6570에서 제안된 표준으로써 OAuth 2.0 보호 자원에 접근하게 해주며 자격 증명이 인코딩되는 방식을 정의하는 Authentication Schema입니다.
Authorization: <auth-scheme> <authorization-parameters>
위의 코드와 같은 문법을 사용합니다. <auth-scheme>는 Bearer이고 <authorization-parameters>는 데이터가 암호화 된문자열입니다. 실제 코드에 적용할때는 토큰을 백엔드에서 발급 받은 뒤에 쿠키나 localStorage에 저장해서 인증이 필요한 페이지에 접근했을 때 HTTP header에게 요청합니다.
// 글 목록 페이지에 인증된 사용자만 접근하는 GET 요청 보내기
axios
.get('http://localhost:1337/posts', {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then(response => {
console.log('Data: ', response.data);
})
.catch(err => {
console.log('An error occurred:', err.response);
});
프로젝트 흐름
먼저 시작하기전에 프로젝트 흐름을 알고 가려합니다. 밑의 설명은 불필요하다 생각될 정도로 복잡하지만 프로젝트의 코드를 전부 구현해보시고 다시 보신다면 이해가 잘될 것 같습니다.
프론트엔드 앱은 http://localhost:3000, 백엔드(strapi)는 https://strapi.website.com 라고 가정하겠습니다.
1. 프론트엔드 앱 로그인 화면에서 Connect with Google 버튼을 클릭해서 백엔드(https://strapi.website.com/api/connect/google)로 리다이렉트합니다.
2. 리다이렉트 된 백엔드는 구글 로그인 페이지로 리다이렉트 시켜줍니다.
3. 구글 로그인이 끝나면 구글은 다시 백엔드(https://strapi.website.com/api/connect/google/callback?code=abcdef )로 리다이렉트 시켜줍니다.
4. 이때 백엔드는 구글에게서 유저 정보를 얻기 위해서 코드를 받는데 이 코드는 일정 기간 동안만 인증된 요청을 하는 access_token을 받는 곳에 쓰입니다.
5. 백엔드는 프론트엔드(http://localhost:3000/connect/google/redirect?access_token=eyfvg)로 리다이렉트합니다. 이 과정에서 프론트는 주소창의 query로 access_token을 받습니다.
6. 프론트엔드(http://localhost:3000/connect/google/redirect)는 받은 access_token을 사용해서 백엔드(https://strapi.website.com/api/auth/google/callback?access_token=eyfvg)를 호출하고 Strapi 유저 프로필과 jwt 토큰을 리턴합니다. 마지막으로 리턴한 데이터로 유저 인증을 합니다.
이제 프로젝트를 시작해보겠습니다.
구글 개발자 콘솔, Strapi 설정
전편에서 사용하던 Strapi 백엔드 시스템을 이용해서 구현해보겠습니다.
영어 원문으로 된 문서를 읽고 싶으시거나 구글 이외에 Discord, Twitter, Github 같은 다양한 Provider까지 구현하고 싶으시다면 아래의 링크를 참고해주세요.
https://docs.strapi.io/developer-docs/latest/plugins/users-permissions.html#providers
Users & Permissions - Strapi Developer Docs
Protect your API with a full authentication process based on JWT and manage the permissions between the groups of users.
docs.strapi.io
먼저 구글 로그인을 하기 위해서 구글 개발자 콘솔에 들어가서 여러가지 설정을 해야합니다. 생각보다 복잡하지 않으니 걱정하지 않으셔도 됩니다.
1. 구글 개발자 콘솔에 접속합니다. https://console.developers.google.com/
2. 페이지 맨 위의 Google Cloud 제목 오른쪽의 프로젝트 선택 드랍다운 버튼을 눌러주시고 새 프로젝트를 클릭해주세요.
3. 프로젝트 이름을 입력하신 뒤 만들기 버튼을 클릭해주세요.
4. 로딩이 끝나고 프로젝트가 생성되면 만들었던 프로젝트를 선택하고 왼쪽 네비게이션의 OAuth 동의 화면으로 이동해주세요.
5. User Type을 외부로 설정하고 만들기를 클릭합니다.
6. 빨간 별표로 되있는 정보(ex: 앱 이름)를 입력하고 저장 후 계속을 클릭합니다.
7. 다른 것들은 입력해주실 필요없으며 저장 후 계속을 끝날 때까지 클릭합니다.
8. 완료되면 왼쪽 네비게이션의 사용자 인증 정보로 이동합니다. 파란색 글씨의 + 사용자 인증 정보 만들기를 클릭해주시고 OAuth 클라이언트 ID를 클릭합니다.
9. 애플리케이션 유형을 웹 애플리케이션으로 선택해주세요. 이름은 Strapi Auth으로 적고 승인된 리디렉션 URI는 http://localhost:1337/api/connect/google/callback 로 하겠습니다.
10. 만들기를 클릭하신 뒤 OAuth 2.0 클라이언트 ID에 입력하신 정보로 생성된 것을 확인 할수 있습니다.
11. 이름을 클릭하시면 클라이언트 ID, 클라이언트 보안 비밀이 암호화된 문자열로 있습니다. 나중에 입력해야하기 때문에 기억해주세요.
12. 이제 Strapi 서버를 구동한 뒤, http://localhost:1337/admin/settings/users-permissions/providers 로 이동해주세요.
13. Google provider를 클릭해주신뒤 enabled: true, 방금 확인했던 클라이언트 ID, 클라이언트 보안 비밀을 입력해주세요.
The redirect URL to your front-end app에는 http://localhost:3000/connect/google/redirect 를 입력해주겠습니다.
리다이렉트 버튼 만들기
// features/auth/GoogleLogin.tsx
import * as React from "react";
import Link from "next/link";
const GoogleLogin: React.FunctionComponent = () => {
return (
<>
<Link href={`http://localhost:1337/api/connect/google`}>
<button className="w-36">Connect to google</button>
</Link>
</>
);
};
export default GoogleLogin;
백엔드의 구글 연결 페이지로 리다이렉트 시켜주는 버튼을 만들었습니다. 지금은 텍스트만 있지만 글 마지막에 요즘 대부분 사이트에서 사용하는 구글 버튼 스타일을 완성하겠습니다.
// features/auth/LoginForm.tsx
import { useFormik } from "formik";
import { useRouter } from "next/router";
import GoogleLogin from "./GoogleLogin";
import { useLogin } from "./Queries";
const LoginForm: React.FunctionComponent = () => {
const router = useRouter();
const switchAuthModeHandler = () => router.push("/auth/register");
const { mutate: login, isLoading } = useLogin();
const formik = useFormik({
initialValues: {
identifier: "",
password: "",
},
onSubmit: async (values) => {
const enteredidentifier: string = values.identifier;
const enteredPassword: string = values.password;
if (isLoading) {
return;
}
login(
{
identifier: enteredidentifier,
password: enteredPassword,
},
{
onSuccess: () => {
router.push("/");
},
}
);
},
});
return (
<>
<div className="mt-16">
<div className="flex justify-center items-center">
<div className="w-[450px] min-h-[500px] px-8 py-5">
<h3 className="text-2xl font-medium text-gray-200 text-center">
로그인
</h3>
<form onSubmit={formik.handleSubmit}>
<div className="mt-7">
<input
type="text"
name="identifier"
placeholder="이메일 or 닉네임"
value={formik.values.identifier}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="mt-7">
<input
type="password"
name="password"
placeholder="비밀번호"
value={formik.values.password}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="flex mt-12 justify-between items-center">
<button
className="text-sm"
type="button"
onClick={switchAuthModeHandler}
>
회원가입
</button>
<button type="submit" className="btn-primary">
로그인
</button>
</div>
</form>
//유일하게 바뀐 코드
<GoogleLogin />
</div>
</div>
</div>
</>
);
};
export default LoginForm;
저번 글에서 사용했던 LoginForm 컴포넌트입니다. 유일하게 달라진 것은 <form>태그 밑에 GoogleLogin 버튼을 사용하는 것입니다.
리다이렉트 페이지 만들기
// features/auth/Queries.tsx
import { useQuery } from "@tanstack/react-query";
import client from "../../lib/client";
import { RegisterResult } from "../../types/auth";
import { Error } from "../../types/error";
import toast from "react-hot-toast";
const fetchGoogleLogin = async (token: string): Promise<RegisterResult> => {
const { data } = await client.get(
`http://localhost:1337/api/auth/google/callback?access_token=${token}`
);
return data;
};
export const useGoogleLogin = (token: string) => {
return useQuery(["googleLogin", token], () => fetchGoogleLogin(token), {
onSuccess: () => {},
onError: (error: Error) => {
toast.error("에러가 발생하였습니다.: " + error.message);
},
});
};
컴포넌트 코드를 작성하기전에 먼저 react query 코드를 작성하겠습니다.
함수에 들어가는 token 패러미터는 리다이렉트 된 프론트엔드 페이지가 query로 가지고 있는 access_token입니다.
react query의 역할은 jwt 토큰과 username을 가져오는 곳에만 쓰입니다.
// pages/connect/google/redirect/index.tsx
import React from "react";
import Redirect from "../../../../features/auth/Redirect";
const Index: React.FunctionComponent = () => {
return (
<>
<Redirect />
</>
);
};
export default Index;
폴더명이 정말 중요합니다. 주석에 적힌 경로를 꼭 맞춰주세요.
// features/auth/Redirect.tsx
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { setJwtToken, setUsername } from "../../lib/storage";
import { useGoogleLogin } from "./Queries";
const Redirect: React.FunctionComponent = () => {
const router = useRouter();
const { access_token } = router.query;
const [token, setToken] = useState("");
const [text, setText] = useState("로딩 중...");
const { data, isLoading, isError } = useGoogleLogin(token);
useEffect(() => {
if (router.isReady) {
setToken(String(access_token));
if (isLoading) {
setText("로딩 중입니다...");
}
if (data) {
setJwtToken(data.jwt);
setUsername(data.user.username);
setText("성공적으로 로그인하였습니다. 몇초 후 리다이렉트 됩니다.. ");
setTimeout(() => router.push("/"), 3000);
}
if (isError) {
setText("에러가 발생하였습니다.");
}
}
}, [data, isLoading, isError, router, access_token]);
return <p>{text}</p>;
};
export default Redirect;
useEffect로 현재 주소창의 query에서 access_token을 가져와 백엔드에 GET 요청을 합니다. 그 다음 루트 페이지로 리다이렉트합니다. useMutation이 아닌 useQuery를 사용한 이유는 보통 로그인 요청은 POST로 보내는데 소셜 로그인의 경우 GET으로 토큰과 유저 데이터를 가져오기 때문입니다.
결과
마지막으로 실행해보기 전에 구글 로그인 버튼 스타일을 완성하겠습니다.
구글에서 제공하는 로그인 브랜드 가이드라인에서 이미지 파일을 다운로드 해주세요.
다운 받은 압축 파일의 web/2x 폴더에서 "btn_google_signin_dark_normal_web@2x.png"파일을 사용하겠습니다.
저는 이름을 "google_normal.png"로 변경한 뒤에 프로젝트의 public/static 폴더에서 넣어주었습니다.
//features/auth/GoogleLogin.tsx
import * as React from "react";
import Link from "next/link";
const GoogleLogin: React.FunctionComponent = () => {
return (
<>
<Link href={`http://localhost:1337/api/connect/google`}>
<button className="w-full flex justify-center">
<img
className="h-[60px]"
src="/static/google_normal.png"
alt="google_login"
/>
</button>
</Link>
</>
);
};
export default GoogleLogin;
GoogleLogin 컴포넌트의 버튼이 이미지 파일을 사용하도록 수정하겠습니다.
이제 오늘 프로젝트의 결과를 확인해보겠습니다.
Sign in with Google 버튼을 누르시면 구글 로그인 페이지로 리다이렉트 됩니다.
구글 로그인을 해주시면 메인 페이지로 리다이렉트되면서 인증이 완료된 것을 확인 할 수 있습니다.
로그인 하신 후 쿠키를 확인주세요. 저번 글에서 구현한 로그인 기능과 똑같이 동작합니다.
다음 시간에는 refresh token, access token을 모두 활용해서 OAuth 2.0에서 권장하는 방식으로 구글 인증을 구현해보겠습니다. 먼저 Testing과 Github CI/CD 글을 쓴 뒤 이어가게 될 것 같습니다. 긴 글 읽어주셔서 감사합니다.
'Development > React' 카테고리의 다른 글
React) useState에서 놓치기 쉬운 사실들 (3) (0) | 2022.12.26 |
---|---|
React) useState에서 놓치기 쉬운 사실들 (2) (0) | 2022.12.26 |
React) useState에서 놓치기 쉬운 사실들 (1) (0) | 2022.12.23 |
Next.js 쿠키와 JWT 토큰으로 로그인 구현 (0) | 2022.12.05 |
Tailwind CSS 사용해보기 (0) | 2022.08.29 |