배경
프로젝트를 진행하면서 OAuth2를 사용하여 소셜 로그인(Naver, Kakao, Google)을 구현하게 되었다.
이전 졸업 작품 등에서도 OAuth2로 소셜 로그인을 구현한 경험은 있지만, 전부 Java/Springboot를 이용하여 구현하여서 Express를 사용하여 구현한 것은 처음이었다!
그치만 Springboot에서 Express로의 변경에 의해 야기된 큰 차이점은 없었다.
단, 이번에 OAuth2 로그인을 구현하면서 고려해야할 점은 다음과 같았다.
- 닉네임(nickname) 정보가 반드시 필요하다.
- 닉네임이 unique값이어야 한다. (즉, 겹치면 안 된다.)
그런데, 다음과 같은 문제가 있었다.
- Provider로부터 얻어온 정보에 닉네임 정보가 없는 경우가 존재한다.
- 얻어온 닉네임의 unique함이 보장되지 않는다.
naver, google의 경우 name을 가져와 닉네임으로 설정함으로써 정보가 없는 경우가 없었지만,
kakao의 경우 nickname 정보를 바로 가져오기 때문에 사용자가 닉네임을 설정하지 않은 경우 정보가 없을 수 있다.
결국 이를 해결하기 위해 다음 사항을 구현하여야 했다.
Provider로부터 받아온 정보가 우리의 어플리케이션 상에서 유효하지 않은 정보인 경우 사용자로부터 직접 유효한 입력을 받아 OAuth2 로그인을 마무리한다.
일단 기본적인 Oauth2 로그인 구현에서부터, 위 내용을 구체적으로 어떻게 해결했는지 기록해보려고 한다.
Flow
OAuth2 로그인의 동작 플로우에 대해서는 oauth2 로그인 검색어로 구글에 검색해보면 아주 자세하게 설명해주신 블로그들이 많다 ~.~
여기서는 개발자 입장에서 간단히만 풀어보겠다.
- 사용자가 소셜 로그인을 클릭할 경우, 인증 서비스 Provider에서 제공하는 '로그인 페이지'로 이동시킨다.
- 이 때 미리 발급한 client secret, redirect_uri 등의 정보를 넣어서 get 요청을 보낸다.
- 사용자가 '로그인 페이지'에서 SNS 로그인을 성공적으로 끝내면, Provider가 code를 쿼리로 넣어서 redirect_uri로 리다이렉트 해준다.
- 만일 SNS 로그인 과정에서 실패하면, error를 쿼리로 넣어서 리다이렉트 해준다. (따라서 이 때 error 처리를 해준다.)
- Provider로부터 받아온 code 정보를 포함하여 Provider에 access_token을 요청(Post)한다.
- Provider는 access_token, refresh_token 등을 response로 응답한다.
- 응답받은 access_token 정보를 사용하여 사용자의 정보를 요청(Get)한다.
- access_token 를 Authorization 헤더에 추가하여 정보를 요청한다.
- Provider로부터 받아온 사용자 정보를 사용하여 로그인 or 회원가입을 진행한다.
기본 구현
기본적인 oauth2 앱 생성, 어플리케이션 등록 등은 완료된 상태에서 시작합니다 💨
1. SNS 로그인 페이지로 이동 시키기
사용자가 소셜 로그인을 클릭할 경우, 인증 서비스 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의 명세 페이지만 아래에 남겨둔다!
OAuth2 로그인 URL 관리
각 로고 클릭 시 이동하는 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 요청이 들어온다. )
2. code / error 응답 받기
사용자가 '로그인 페이지'에서 SNS 로그인을 성공적으로 끝내면, Provider가 code를 쿼리로 넣어서 redirect_uri로 리다이렉트 해준다.
- 만일 SNS 로그인 과정에서 실패하면, error를 쿼리로 넣어서 리다이렉트 해준다. (따라서 이 때 error 처리를 해준다.)
이제부터는 개발자가 redirect_uri에 프론트쪽 주소를 입력하였느냐, 백쪽 주소를 입력하였느냐에 따라 구현 방향이 갈린다.
나는 프론트엔드와 백엔드의 책임을 확실히 구분하는 게 좋을 것 같다는 생각에 백엔드쪽 주소로 리디렉션을 받고, 나머지 로그인 작업을 실행하도록 구현하였다.
oauthRouter.js
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 로 이동한다.
oauth2Middleware.js
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마다 인증 요청에 필요한 값과 응답으로 들어오는 데이터의 구조가 다르기 때문이다.
3 & 4. access_token, user 정보 요청하기
3. Provider로부터 받아온 code 정보를 포함하여 Provider에 access_token을 요청(Post)한다.
- Provider는access_token, refresh_token 등을 response로 응답한다.
4. 응답받은 access_token 정보를 사용하여 사용자의 정보를 요청(Get)한다.
- access_token 를 Authorization 헤더에 추가하여 정보를 요청한다.
oauth2GoogleHandler.js
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,
};
};
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');
}
};
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;
셋 다 올리면 길이가 너무 길어지는 관계로 oauth2GoogleHanlder만 대표로 올려본다.
나머지도 내용은 거의 같으며 다만 각 Provider가 요구하는 요청 형식과, 응답 객체 이름에 주의하여 나머지도 구현하면 된다.
세 경우 모두 정상적으로 userInfo를 얻어오는 데에 성공한다면, request 객체 내에 userInfo 라는 이름으로 정보를 '모두 같은 형식'으로 저장한다. Java의 경우 class로, Typescript의 경우 interface로 구조화하여 타입 검증 등을 구현하면 좋겠지만 JS는 ... 주의 또 주의 ...
하여 각 oauth2{Provider}Handler는
1. code로 access_token을 받아오는 역할
2. access_token으로 사용자 정보를 받아오는 역할
3. 받아온 사용자 정보를 같은 형식으로 구조화하여 controller에 전달하는 역할
을 수행한다!
그리고 다시 oauth2Middleware로 돌아와 next(); 구문에 의해 OAuth2Controller.SocialLogin 메서드가 실행된다.
OAuth2Controller.js
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); 구문을 통해 에러 핸들러로 역할이 위임된다.
OAuth2Service.js
1. 만일 이미 회원가입이 되어있는 이메일인 경우, 현재 요청 Provider와 가입된 Provider가 동일한지 확인하고 동일한 경우 로그인 처리를 해준다.
- 현재 email이 unique값으로 쓰이고 있기 때문에 이렇게 처리를 해주었다.
2. 가입이 되어 있지 않다면, 회원가입을 진행해준다.
그리고 이제 여기서부터 nickname에 대한 처리가 시작된다.
3. 만일 userInfo에 닉네임이 존재하지 않거나, 혹은 존재하지만 이미 DB에 존재하는 닉네임일 경우 NICKNAME_REQUIRED/DUPLICATED_NICKNAME 에러를 발생시킨다.
4. 만일 닉네임이 유효한 경우, 그대로 userInfo를 사용하여 회원가입을 진행시키고, 로그인 처리를 해주어 소셜 로그인을 끝낸다.
- password는 우선 빈 값으로 넣어놓았으나 이에 대한 후속 처리도 필요할 듯 하다.
만일 닉네임이 유효하지 않아 에러가 발생하면, OAuth2Controller.SocialLogin 에서의 에러 핸들링 코드에 의해 OAuth2ErrorHandler로 책임이 위임된다.
nickname 정보 받아 회원가입 진행하기 구현
자, 이제 유효한 nickname을 사용자로부터 받아와서 회원가입을 마무리 하여야 한다.
이를 구현한 방법을 정리하면 다음과 같다.
- Backend : cookie에 userInfo 정보를 넣은 뒤, 약속된 프론트엔드 주소로 리다이렉트한다.
- Frontend : 닉네임 설정이 필요한 경우에 약속된 주소로 리다이렉트된 경우, 백엔드에 userInfo 정보를 요청한다.
- Backend : userInfo 정보 요청이 들어오면 cookie에서 userInfo를 꺼내어 응답한다.
- Frontend : 사용자에게 닉네임을 입력받고, 백엔드에서 받아온 userInfo와 함께 재'회원가입'을 요청한다.
- Backend : 닉네임의 유효성을 검증하고 회원가입을 마무리한다.
1. [BE] cookie에 userInfo를 넣어 리다이렉트
OAuth2ErrorHandler.js
cookie에 userInfo를 저장한다.
이 때, 보안을 위해 httpOnly : true 로 설정한다.
사실상 지금 userInfo에는 이메일, 닉네임, (아무것도 들어있지 않은) password, provider 정보 등 민감하지 않은 정보만 들어있어서 httpOnly 설정을 false로 한 뒤, 프론트엔드에서 직접 cookie를 까서 userInfo 정보를 얻는 방법도 가능하다.
그렇지만 .. 나중에 다른 민감한 정보를 추가할 수도 있고, 사용자의 이메일 정보 만으로도 피싱 공격에 악용될 수 있으므로 어떤 것이든 사용
자 정보는 항상 안전하게 다루는 것이 중요하다.
httpOnly : true로 설정 시, 브라우저의 보안 정책에 의해 클라이언트 자바스크립트로는 해당 쿠키에 접근할 수가 없다.
따라서 프론트엔드에서는 특정 주소로 리다이렉트가 된 경우, userInfo 정보를 백엔드 측으로 요청하여야한다.
25번째 줄을 보면, 기존 OAUTH_REDIRECT_URI 에 /nickname이 추가된 것을 확인할 수 있다.
이 경로가 바로 '닉네임 설정이 필요한 경우에 약속된 주소'에 해당한다.
2. [FE] 백엔드에 userInfo 요청
프론트엔드 쪽의 라우터를 보자.
<Route path="/oauth2/redirect" element={<OAuth2RedirectHandler />} />
<Route path="/oauth2/redirect/nickname" element={<NicknameSettingPage />} />
- 위는 소셜로그인이 성공되었거나, 혹은 닉네임 설정이 필요한 경우가 아닌 다른 경우에서의 에러가 발생했을 경우 리다이렉트 되는 경로이다.
- 아래는 닉네임 설정이 필요할 경우 리다이렉트 되는 경로이다.
setNicknameForm.js
길다 (^^)
중요한 사항만 정리해서 관련된 코드와 함께 적어본다.
현재 React를 이용하여 프론트엔드 개발 중이며, redux와 redux-saga를 사용하여 상태 & api 요청을 관리 중이다.
- 해당 페이지 진입시, 백엔드로 userInfo를 요청한다.
// inintial rendering
useEffect(() => {
dispatch(getUserInfo());
return () => dispatch(initializeForm('nicknameRegister'));
}, [dispatch]);
- 응답받은 userInfo를 form으로 설정한다.
const form = useSelector((state) => state.auth.userInfo);
- 사용자에게 닉네임을 입력받아 form의 nickname으로 설정한다.
const onChange = (e) => {
const { value, name } = e.target;
dispatch(
changeField({
form: 'userInfo',
key: name,
value: value,
}),
);
};
- 해당 form으로 백엔드에 register를 요청한다.
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]);
api 요청 코드
// userInfo 요청
export const getUserInfo = () => client.get('/api/oauth/userinfo');
// userInfo 등록 요청
export const registerUserInfo = (userInfo) => client.post('/api/oauth/register', userInfo);
3 & 4. [BE] userInfo 응답 & OAuth2 회원가입 마무리
OAuthRouter.js
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;
OAuthController.js 추가 코드
- userInfo 요청이 들어오면, 해당 요청 객체의 쿠키에서 userInfo를 가져와 응답한다.
- register 요청이 들어오면, 회원가입을 재진행하고, 회원가입 성공 시 로그인 처리한다.
- 이 때 닉네임의 유효성 여부를 검증한다.
끝이다 !
결국 Provider에서부터 받아온 정보를 cookie에 저장하여 보존하고, 닉네임만 사용자로부터 받아와 cookie에 저장된 정보와 함께 회원가입을 진행하는 것이다.
마무리
내가 구현한 방법 외에도 정말 온갖 방법이 다 있겠지만 일단 나는 이렇게 구현해보았다.
생각해보면, 백엔드에서 쿠키를 저장한다음 프론트에서 userInfo를 요청하지 않고 회원가입을 진행하는 방법도 있을 것 같다.
입력 받은 nickname을 가지고 register를 요청하면 백엔드에서 nickname 유효성을 확인하고, 만일 유효한 경우 그 때 쿠키에서 userInfo를 가져와 회원가입을 진행하는 방법도 괜찮을 것 같다.
만일 여기까지 읽은 사람이 있다면 ... 위 코드들은 리팩토링과 디테일 처리가 되지 않은 코드임을 알아주었으면 좋겠습니다 🙃 (특히 에러처리 뒤죽박죽)!
전 개발 콩나물이므로 일러주고 싶은 개선사항이 있다면 참견은 언제나 환영입니닷
'Project' 카테고리의 다른 글
SpringBoot + QueryDSL 적용 살펴보기 (2) | 2024.11.25 |
---|---|
[BAW] Express + PostgreSQL 트랜잭션 처리 도입기 (1) | 2024.11.09 |
[BAW] 중간점검 .. 프로젝트 모듈 구조화 (0) | 2024.11.07 |