
프로젝트를 진행하면서 OAuth2를 사용하여 소셜 로그인(Naver, Kakao, Google)을 구현하게 되었다.
이전 졸업 작품 등에서도 OAuth2로 소셜 로그인을 구현한 경험은 있지만, 전부 Java/Springboot를 이용하여 구현하여서 Express를 사용하여 구현한 것은 처음이었다!
그치만 Springboot에서 Express로의 변경에 의해 야기된 큰 차이점은 없었다.
단, 이번에 OAuth2 로그인을 구현하면서 고려해야할 점은 다음과 같았다.
그런데, 다음과 같은 문제가 있었다.
결국 이를 해결하기 위해 다음 사항을 구현하여야 했다.
Provider로부터 받아온 정보가 우리의 어플리케이션 상에서 유효하지 않은 정보인 경우 사용자로부터 직접 유효한 입력을 받아 OAuth2 로그인을 마무리한다.
일단 기본적인 Oauth2 로그인 구현에서부터, 위 내용을 구체적으로 어떻게 해결했는지 기록해보려고 한다.
OAuth2 로그인의 동작 플로우에 대해서는 oauth2 로그인 검색어로 구글에 검색해보면 아주 자세하게 설명해주신 블로그들이 많다 ~.~
여기서는 개발자 입장에서 간단히만 풀어보겠다.
code를 쿼리로 넣어서 redirect_uri로 리다이렉트 해준다. error를 쿼리로 넣어서 리다이렉트 해준다. (따라서 이 때 error 처리를 해준다.)code 정보를 포함하여 Provider에 access_token을 요청(Post)한다. access_token, refresh_token 등을 response로 응답한다.access_token 정보를 사용하여 사용자의 정보를 요청(Get)한다.access_token 를 Authorization 헤더에 추가하여 정보를 요청한다.기본적인 oauth2 앱 생성, 어플리케이션 등록 등은 완료된 상태에서 시작합니다 💨
사용자가 소셜 로그인을 클릭할 경우, 인증 서비스 Provider에서 제공하는 '로그인 페이지'로 이동시킨다.
- 이 때 미리 발급한 client secret, redirect_uri 등의 정보를 넣어서 get 요청을 보낸다.
로그인 창에서 각 SNS의 로고를 클릭하면, 해당 SNS에서 제공하는 OAuth2 로그인 창으로 이동되도록 한다.
const SocialLogin = () => {
return (
<SocialLoginBlock>
<div>
<a href={GOOGLE_AUTH_URL}>
<SocialLogo src={google} alt="google login" />
</a>
<a href={NAVER_AUTH_URL}>
<SocialLogo src={naver} alt="naver login" />
</a>
<a href={KAKAO_AUTH_URL}>
<SocialLogo src={kakao} alt="kakao login" />
</a>
</div>
</SocialLoginBlock>
);
};
이 때 client_id, redirect_uri, response_type 등등의 정보들을 쿼리로 함께 넘겨줘야 하는데, 이는 각 SNS Provider마다 상이해서 직접 공식 문서를 확인해보아야 한다.
내가 구현한 Google, Naver, Kakao의 명세 페이지만 남겨둔다!
각 로고 클릭 시 이동하는 url은 constants로 분류하여 위 사진처럼 하나의 파일 속에서 관리하고 있다.
client id나client secret등은 민감한 정보이므로dotenv라이브러리를 설치하여 반드시.env파일로 관리해줄 것 !!
url(각 SNS 로그인 페이지로의 요청)의 구성요소를 들여다보면, REDIRECT_URI가 있다.
이 REDIRECT_URI에는 각 Provider에서 client id, secret을 받기 위해 어플리케이션 등록을 할 때 함께 설정한 redirect_uri와 동일한 주소를 입력해주어야 한다.
Provider는 사용자의 로그인 결과에 따라 이 redirect_uri로 code 혹은 error 정보를 포함하여 리다이렉트 해준다. ( = 해당 경로로 code 혹은 error 쿼리를 가진 get 요청이 들어온다. )
사용자가 '로그인 페이지'에서 SNS 로그인을 성공적으로 끝내면, Provider가
code를 쿼리로 넣어서redirect_uri로 리다이렉트 해준다.
- 만일 SNS 로그인 과정에서 실패하면,error를 쿼리로 넣어서 리다이렉트 해준다. (따라서 이 때 error 처리를 해준다.)
이제부터는 개발자가 redirect_uri에 프론트쪽 주소를 입력하였느냐, 백쪽 주소를 입력하였느냐에 따라 구현 방향이 갈린다.
나는 프론트엔드와 백엔드의 책임을 확실히 구분하는 게 좋을 것 같다는 생각에 백엔드쪽 주소로 리디렉션을 받고, 나머지 로그인 작업을 실행하도록 구현하였다.
import { Router } from 'express';
import OAuthController from '../controllers/oauthController.js';
import oauth2Middleware from '../lib/oauth2/oauth2Middleware.js';
import oauth2ErrorHandler from '../lib/oauth2/oauth2ErrorHandler.js';
const oauthRouter = Router();
oauthRouter.get(
'/callback/:provider',
oauth2Middleware,
OAuthController.socialLogin,
oauth2ErrorHandler,
);
export default oauthRouter;
Provider로부터 앱 생성 시 등록한 redirect_uri로 get 요청이 들어온다. (with code/error)
보다시피 oauth2Middleware -> OAuthController -> (에러 발생 시) oauth2ErrorHandler 로 이동한다.
import AuthProvider, { mapAuthProvider } from '../../constants/authProvider.js';
import oauth2GoogleHandler from './oauth2GoogleHandler.js';
import oauth2KakaoHandler from './oauth2KakaoHandler.js';
import oauth2NaverHandler from './oauth2NaverHandler.js';
const oauth2Middleware = async (req, res, next) => {
const { provider } = req.params;
const { error } = req.query;
try {
if (error) {
throw new Error('로그인 창으로 돌아갑니다.');
}
switch (mapAuthProvider(provider)) {
case AuthProvider.GOOGLE:
await oauth2GoogleHandler(req, res);
return next();
case AuthProvider.NAVER:
await oauth2NaverHandler(req, res);
return next();
case AuthProvider.KAKAO:
await oauth2KakaoHandler(req, res);
return next();
default:
throw Error('unknown provider');
}
} catch (e) {
return res.redirect(
`${process.env.OAUTH_REDIRECT_URI}?error=${encodeURIComponent(
e.message,
)}`,
);
}
};
export default oauth2Middleware;
oauth2Middleware는 Provider에 따라서 각 Provider에 맞는 Handler로 권한을 위임한다.
하나의 미들웨어로 모든 Provider의 로그인 처리를 구현하지 않는 이유는 각 Provider마다 인증 요청에 필요한 값과 응답으로 들어오는 데이터의 구조가 다르기 때문이다.
셋 다 올리면 길이가 너무 길어지는 관계로 oauth2GoogleHanlder만 대표로 올려본다.
나머지도 내용은 거의 같으며 다만 각 Provider가 요구하는 요청 형식과, 응답 객체 이름에 주의하여 나머지도 구현하면 된다.
import AuthProvider, from '../../constants/authProvider.js';
import axios from 'axios';
const oauth2GoogleHandler = async (req, res) => {
const { code } = req.query;
// 1. code로 access_token 얻어오기
const tokenData = await requestGoogleAccessToken(code);
// 2. access_token으로 userInfo 얻어오기
const userInfo = await requestGoogleUserinfo(tokenData);
req.userInfo = {
email: userInfo.email,
nickname: userInfo.name,
password: '',
authProvider: AuthProvider.GOOGLE,
};
};
Provider로부터 받아온 code 정보를 포함하여 Provider에 access_token을 요청(Post)한다.
- Provider는access_token, refresh_token 등을 response로 응답한다.
const requestGoogleAccessToken = async (code) => {
try {
const res = await axios.post(process.env.GOOGLE_TOKEN_URL, {
code: code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
grant_type: 'authorization_code',
});
return res.data;
} catch (e) {
console.log(e.message);
throw new Error('Failed to request access token');
}
};
응답받은 access_token 정보를 사용하여 사용자의 정보를 요청(Get)한다.
const requestGoogleUserinfo = async (data) => {
try {
const res = await axios.get(process.env.GOOGLE_USERINFO_URL, {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
});
return res.data;
} catch (e) {
console.log(e.message);
throw new Error('Failed to request user info');
}
};
export default oauth2GoogleHandler;
세 경우 모두 정상적으로 userInfo를 얻어오는 데에 성공한다면, request 객체 내에 userInfo 라는 이름으로 정보를 '모두 같은 형식'으로 저장한다.
Java의 경우 class로, Typescript의 경우 interface로 구조화를 진행하면 좋겠지만 JS는 구조화를 할 방법이 없으니 주의 또 주의 ...
그리고 다시 oauth2Middleware로 돌아와 next(); 구문에 의해 OAuth2Controller.SocialLogin 메서드가 실행된다.
import AuthErrorMessage from '../constants/error/authErrorMessage.js';
import OAuthService from '../services/oauthService.js';
import * as tokenUtil from '../utils/tokenUtil.js';
class OAuthController {
/**
* 리디렉션 from Provider
* GET /api/oauth/callback/:provider?code=
*/
static async socialLogin(req, res, next) {
const userInfo = req.userInfo;
try {
const data = await OAuthService.processSocialLogin(userInfo);
// 성공적으로 소셜 로그인 완료
// jwt 토큰 발급
const token = tokenUtil.generateToken(data);
tokenUtil.setTokenCookie(res, token);
data.message = '로그인 되었습니다.';
res.redirect(process.env.OAUTH_REDIRECT_URI);
} catch (e) {
// 소셜 로그인 실패
console.error('Social login error:', e.message, 'User info:', userInfo);
// error handler에 역할 위임
return next(e);
}
}
export default OAuthController;
이전 handler에서 저장해놨던 userInfo 정보를 가져와서 OAuthService에게 넘겨주고, OAuth2Service에서 로그인이나 회원가입을 진행하도록 한다.
현재 로그인 유지 방안으로 Jwt을 사용하고 있어서, 소셜 로그인 완료 시 cookie에 jwt를 넣은 후에 약속된 프론트 주소로 redirect를 해준다.
만일 에러가 발생한다면? next(e); 구문을 통해 에러 핸들러로 역할이 위임된다.
그리고 이제 여기서부터 nickname에 대한 처리가 시작된다.
만일 userInfo에 닉네임이 존재하지 않거나, 혹은 존재하지만 이미 DB에 존재하는 닉네임일 경우 NICKNAME_REQUIRED/DUPLICATED_NICKNAME 에러를 발생시킨다.
만일 닉네임이 유효한 경우, 그대로 userInfo를 사용하여 회원가입을 진행시키고, 로그인 처리를 해주어 소셜 로그인을 끝낸다.
만일 닉네임이 유효하지 않아 에러가 발생하면, OAuth2Controller.SocialLogin 에서의 에러 핸들링 코드에 의해 OAuth2ErrorHandler로 책임이 위임된다.
자, 이제 유효한 nickname을 사용자로부터 받아와서 회원가입을 마무리 하여야 한다.
이를 구현한 방법을 정리하면 다음과 같다.
Backend: cookie에 userInfo 정보를 넣은 뒤, 약속된 프론트엔드 주소로 리다이렉트한다.Frontend: 닉네임 설정이 필요한 경우에 약속된 주소로 리다이렉트된 경우, 백엔드에 userInfo 정보를 요청한다.Backend: userInfo 정보 요청이 들어오면 cookie에서 userInfo를 꺼내어 응답한다.Frontend: 사용자에게 닉네임을 입력받고, 백엔드에서 받아온 userInfo와 함께 재'회원가입'을 요청한다.Backend: 닉네임의 유효성을 검증하고 회원가입을 마무리한다.
cookie에 userInfo를 저장한다.
이 때, 보안을 위해 httpOnly : true 로 설정한다.
사실상 지금 userInfo에는 이메일, 닉네임, (아무것도 들어있지 않은) password, provider 정보 등 민감하지 않은 정보만 들어있어서 httpOnly 설정을 false로 한 뒤, 프론트엔드에서 직접 cookie를 까서 userInfo 정보를 얻는 방법도 가능하다.
그렇지만 .. 나중에 다른 민감한 정보를 추가할 수도 있고, 사용자의 이메일 정보 만으로도 피싱 공격에 악용될 수 있으므로 어떤 것이든 사용자 정보는 항상 안전하게 다루는 것이 중요하다.
httpOnly : true로 설정 시, 브라우저의 보안 정책에 의해 클라이언트 자바스크립트로는 해당 쿠키에 접근할 수가 없다.
따라서 프론트엔드에서는 특정 주소로 리다이렉트가 된 경우, userInfo 정보를 백엔드 측으로 요청하여야한다.
25번째 줄을 보면, 기존 OAUTH_REDIRECT_URI 에 /nickname이 추가된 것을 확인할 수 있다.
이 경로가 바로 '닉네임 설정이 필요한 경우에 약속된 주소'에 해당한다.
프론트엔드 쪽의 라우터를 보자.
<Route path="/oauth2/redirect" element={<OAuth2RedirectHandler />} />
<Route path="/oauth2/redirect/nickname" element={<NicknameSettingPage />} />
길다 (^^)
중요한 사항만 정리해서 관련된 코드와 함께 적어본다.
현재 React를 이용하여 프론트엔드 개발 중이며, redux와 redux-saga를 사용하여 상태 / api 요청을 관리 중이다.
// inintial rendering
useEffect(() => {
dispatch(getUserInfo());
return () => dispatch(initializeForm('nicknameRegister'));
}, [dispatch]);
const form = useSelector((state) => state.auth.userInfo);
const onChange = (e) => {
const { value, name } = e.target;
dispatch(
changeField({
form: 'userInfo',
key: name,
value: value,
}),
);
};
const onSubmit = (e) => {
e.preventDefault();
dispatch(registerUserInfo(form));
};
useEffect(() => {
if (authError) {
console.log('회원가입 실패');
setError(authError.response.data.message);
return;
} else {
setError(null);
}
if (auth) {
dispatch(check());
window.alert(`${form.nickname}님의 가입을 환영합니다!`);
}
}, [auth, authError, dispatch]);
useEffect(() => {
if (user) {
navigate('/');
try {
localStorage.setItem('user', JSON.stringify(user));
} catch (e) {
console.log('localStorage is not working');
}
}
}, [user, navigate]);
// userInfo 요청
export const getUserInfo = () => client.get('/api/oauth/userinfo');
// userInfo 등록 요청
export const registerUserInfo = (userInfo) => client.post('/api/oauth/register', userInfo);
import { Router } from 'express';
import OAuthController from '../controllers/oauthController.js';
import oauth2Middleware from '../lib/oauth2/oauth2Middleware.js';
import oauth2ErrorHandler from '../lib/oauth2/oauth2ErrorHandler.js';
const oauthRouter = Router();
oauthRouter.get(
'/callback/:provider',
oauth2Middleware,
OAuthController.socialLogin,
oauth2ErrorHandler,
);
/** userInfo 응답 **/
oauthRouter.get('/userinfo', OAuthController.getUserInfoFromCookie);
/** 사용자 입력 닉네임으로 재회원가입 시도 **/
oauthRouter.post('/register', OAuthController.register);
export default oauthRouter;
끝이다 !
결국 Provider에서부터 받아온 정보를 cookie에 저장하여 보존하고, 닉네임만 사용자로부터 받아와 cookie에 저장된 정보와 함께 회원가입을 진행하는 것이다.
내가 구현한 방법 외에도 정말 온갖 방법이 다 있겠지만 일단 나는 이렇게 구현해보았다.
생각해보면, 백엔드에서 쿠키를 저장한다음 프론트에서 userInfo를 요청하지 않고 회원가입을 진행하는 방법도 있을 것 같다.
입력 받은 nickname을 가지고 register를 요청하면 백엔드에서 nickname 유효성을 확인하고, 만일 유효한 경우 그 때 쿠키에서 userInfo를 가져와 회원가입을 진행하는 방법도 괜찮을 것 같다.
만일 여기까지 읽은 사람이 있다면 ... 위 코드들은 리팩토링과 디테일 처리가 되지 않은 코드임을 알아주었으면 좋겠습니다 🙃 (특히 에러처리 뒤죽박죽)!
전 개발 콩나물이므로 일러주고 싶은 개선사항이 있다면 참견은 언제나 환영입니닷