본문으로 바로가기

728x90

파이어 베이스는 각종 앱을 쉽게 만들기 위해 제공되는 프로젝트 플랫폼입니다. OAuth2 로그인, 데이터베이스, 기계학습과 호스팅 등을 제공합니다. 파이어 베이스를 잘 활용하면 비용 없이 앱 서비스를 제공이 가능합니다. 또한, 구글에서 제공되는 다양한 확장 기능을 통합할 수 있습니다.

 

필자는 AWS 기반으로 개인적인 프로젝트를 진행한 적이 있는데 이때 서버의 운영비용이 개인적으로 부담하기에는 지불해야 할 비용이 높았습니다. 물론 제공되는 서버의 사양도 낮아서 간혹 메모리 부족으로 죽는 현상도 발생하였습니다.

 

이번 연재에서는 파이어 베이스 기반의 OAuth2 로그인과 데이터베이스를 사용하는 예제를 위주로 설명합니다. 그리고 추가적으로 카카오와 네이버의 OAuth2를 파이어 베이스에 확장 적용하는 방법에 대해서도 구현할 예정입니다.

 

연재는 아래와 같은 순서로 작업할 연재할 예정이며 순서는 달라질 수 있습니다.

 

프로젝트 생성

1. Vue + Firebase + Vuetify

 

인증

2. 전자우편/비밀번호

3. 전자우편 링크

4. 구글 로그인

5. 페이스북 로그인

6. 카카오톡 로그인

7. 네이버 로그인

 


이 번 글에서는 네이버 로그인을 사용하는 방법에 대해서 설명합니다. 지난 포스트에서는 카카오 로그인을 구현하는 방법에 대해서 설명하였습니다.

 

네이버 로그인 처리 개요

네이버 로그인 처리 순서

  1. 네이버 로그인 요청
  2. 네이버 로그인 동의 및 로그인
  3. 콜백 페이지로 네이버 인증 코드 반환
  4. 파이어 베이스에 네이버 인증 코드 전달
    1. REST API를 통해 인증코드를 전달하여 네이버 Access Token 요청 및 응답 처리
    2. 네이버 Access Token을 통해 사용자 정보 요청 및 응답 처리
    3. 네이버 사용자 정보가 있을 경우 파이어 베이스에 회원을 등록 또는 정보 갱신
    4. 등록 및 갱신 후 파이어베이스 계정 정보 취득
      1. 이미 존재하는 전자우편의 경우에는 전자우편을 통해 사용자 정보 조회
    5. 계정 정보 반환 시 파이어베이스 Access Token 생성
    6. 파이어 베이스 Access Token 및 네이버 Access Token을 웹 브라우저에 전달
  5. 파이어 베이스로 전달받은 Access Token을 이용해 네이버 및 파이어 베이스 로그인 처리

 

네이버 애플리케이션 등록하기

네이버 애플리케이션 등록

네이버 개발자 센터 접속

네이버 개발자 센터(https://developers.naver.com/main/)에 접속합니다.

네이버 아이디로 로그인 API 클릭

상단 메뉴에서 [네이버 아이디로 로그인]을 선택합니다.

네이버 아이디로 로그인 오픈 API 이용 신청

[오픈 API 이용 신청]을 클릭합니다.

약관동의

약관에 동의합니다.

 

약관 동의

계정 정보 등록 - 전화인증

휴대폰 인증을 완료합니다.

계정 정보 등록 - 전화인증

계정 정보 등록

계정 정보 등록

애플리케이션 등록

아래의 내용을 참고하여 네이버 로그인을 위한 애플리케이션을 등록합니다. 본 프로젝트에서는 회원 이름, 이메일, 프로필 사진을 사용하도록 설정하였습니다. 도메인 및 콜백 주소는 [파이어 베이스 메뉴 > Authentication > Sign-method]의 하단 부분을 참고하십시오.

애플리케이션 정보

애플리케이션 등록 정보 확인

애플리케이션 등록정보

 

 

파이어 베이스 프로젝트 수정하기

파이어베이스 로그인 버튼 추가

/src/views/SignIn.vue에 아래의 그림과 같이 네이버 로그인 버튼을 추가하고 클릭 이벤트를 naver() 함수로 구현합니다.

네이버 로그인 버튼 추가

네이버 로그인에서는 state가 필수적으로 필요합니다. state는 사이트 간 요청 위조 공격을 방지하기 위해 애플리케이션에서 생성한 상태 토큰 값입니다. 콜백 페이지에서 이 값이 정상적으로 반환되는지 확인하는 로직이 추가적으로 필요합니다.

......상단 생략......
      naver() {
        const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
        // console.log(state);
        window.localStorage.setItem('naverState', state);

        const protocol = location.protocol;
        const hostName = location.hostname;
        const port = location.port;

        let url = protocol + '//' + hostName + (port ? ':' + port : '');
        url += '/callback/naver';

        const authUrl = 'https://nid.naver.com/oauth2.0/authorize';
        const params = [];
        params.push('response_type=code');
        params.push('client_id=' + process.env.VUE_APP_NAVER_APP_CLIENT_ID);
        params.push('redirect_uri=' + url);
        params.push('state=' + state);

        const authCodeUrl = authUrl + "?" + params.join('&');
        // console.log(authCodeUrl);
        location.href = authCodeUrl;
    },
......하단 생략......

콜백 페이지 추가하기

/src/router/index.js에 콜백 페이지를 추가합니다.

......
      {
        path: 'naver',
        name: 'callback-naver',
        component: () => import('@/views/callback/Naver'),
      },
......

Naver.vue 페이지를 생성하고 다음과 같이 페이지를 편집합니다.

......상단 생략......
  mounted() {
    const _this = this;

    // http://localhost:8080/callback/naver?code=jOlzkxTipHpAZQVW9B&state=epuk4m0rzturg0fuic7m

    const naverState = this.$route.query.state;
    const state = window.localStorage.getItem('naverState');
    window.localStorage.removeItem('naverState');

    // state 값은 일치해야 합니다.
    if(naverState !== state) {
      alert("잘못된 방법으로 접근하셨습니다. 로그인 페이지로 이동합니다.");
      _this.$router.push("/signin");
      return;
    }

    const naverAuthCode = this.$route.query.code;

    if(naverAuthCode) {
      console.log(naverAuthCode);
      // this.token = naverAuthCode;

      // 카카오 로그인 토큰을 파이어베이스 함수에 전달합니다.
      var naverAuth = firebase.functions().httpsCallable('NaverAuth');
      naverAuth({code: naverAuthCode}).then(function(result) {
        console.log(result);

        // Read result of the Cloud Function.
        var naverToken = result.data.naver_token;
        var fireToken = result.data.firebase_token;

        // 토근이 정상적으로 처리될 경우 로그인 처리합니다.
        firebase.auth().signInWithCustomToken(fireToken)
        .then(function(result) {

          _this.token = naverToken;

          window.localStorage.setItem('NaverToken', naverToken);

          const user = result.user;
          console.log("User : ", user);
          if(result.additionalUserInfo.isNewUser) {
            console.log("신규 사용자...");
            _this.$router.push("/welcome");   // welcome
          } else {
            _this.$router.push("/profile");
          }
        })
        .catch(function(error) {
          // Handle Errors here.
          var errorCode = error.code;
          var errorMessage = error.message;
          console.log(errorCode, errorMessage);

          // console.log(error);
          alert("토큰이 정상적이지 않습니다. 만료된 토큰이거나 이미 사용된 토큰입니다.");
          _this.$router.push("/signin");
          return;
        });
      }).catch(function(error) {
        // Getting the Error details.
        var code = error.code;
        var message = error.message;
        var details = error.details;

        // console.log(error);
        // console.log(code, message, details);
        // console.log(error.body);
        
        alert("정상적이지 않은 접근입니다. 만료된 데이터이거나 이미 사용된 데이터입니다.");
        // alert(message + ' ' + details);
        _this.$router.push("/signin");
        return;
      });
    } else {
      alert("잘못된 방법으로 접근하셨습니다. 로그인 페이지로 이동합니다.");
      _this.$router.push("/signin");
      return;
    }
  },
......하단 생략......

 

파이어 베이스 functions 수정하기

함수를 카카오 로그인과 같이 처리할 수 있도록 아래와 같이 수정합니다.

const functions = require('firebase-functions')
const admin = require('firebase-admin')

// CORS Express middleware to enable CORS Requests.
const cors = require('cors')({
    origin: true,
});

require('dotenv').config();

const privateKey = (process.env.FIREBASE_ADMIN_PRIVATE_KEY || "").split("\\n").join("\n");
const serviceAccount = {
    "type": process.env.FIREBASE_ADMIN_TYPE,
    "project_id": process.env.FIREBASE_ADMIN_PROJECT_ID,
    "private_key_id": process.env.FIREBASE_ADMIN_PRIVATE_KEY_ID,
    "private_key": privateKey,
    "client_email": process.env.FIREBASE_ADMIN_CLIENT_EMAIL,
    "client_id": process.env.FIREBASE_ADMIN_CLIENT_ID,
    "auth_uri": process.env.FIREBASE_ADMIN_AUTH_URI,
    "token_uri": process.env.FIREBASE_ADMIN_TOKEN_URI,
    "auth_provider_x509_cert_url": process.env.FIREBASE_ADMIN_AUTH_PROVIDER_CERT_URL,
    "client_x509_cert_url": process.env.FIREBASE_ADMIN_CLIENT_CERT_URL
}


admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: process.env.FIREBASE_DATABASE_URL
})

const request = require('request-promise')


/**
 * createOrFindUser - Update Firebase user with the give email, create if
 * none exists.
 *
 * @param  {Object} params        user id per app
 * @return {Prommise<UserRecord>} Firebase user record in a promise
 */
function createOrFindUser(params) {
    // 신규 사용자 등록
    return admin.auth().createUser(params)
    .catch((err) => {
        // 동일한 메일주소로 이미 가입되어 있는 경우에 사용자 검색하여 반환
        if(err.code === 'auth/email-already-exists') {
            console.log(err);
            // console.log('auth/email-already-exists --------------- ', email);
            return admin.auth().getUserByEmail(params['email']);
        } else {
            throw err;
        }
    });
}

/**
 * updateOrCreateUser - Update Firebase user with the give email, create if
 * none exists.
 *
 * @param  {String} userId        user id per app
 * @param  {String} email         user's email address
 * @param  {String} displayName   user
 * @param  {String} photoURL      profile photo url
 * @return {Prommise<UserRecord>} Firebase user record in a promise
 */
function updateOrCreateUser(userId, email, displayName, photoURL, provider) {
    console.log('updating or creating a firebase user');
    const updateParams = {
        provider: provider,
        displayName: displayName,
    };
    if (displayName) {
        updateParams['displayName'] = displayName;
    } else {
        updateParams['displayName'] = email;
    }
    if (photoURL) {
        updateParams['photoURL'] = photoURL;
    }
    console.log(updateParams);

    // 사용자 정보 갱신
    return admin.auth().updateUser(userId, updateParams)
    .catch((error) => {
        if (error.code === 'auth/user-not-found') {
            updateParams['uid'] = userId;
            if (email) {
                updateParams['email'] = email;
            }

            return createOrFindUser(updateParams);
        }
        throw error;
    });
}

/**
 * createFirebaseToken - returns Firebase token using Firebase Admin SDK
 *
 * @param  {String} accessToken access token from Login API
 * @return {Promise<String>}                  Firebase token in a promise
 */
const requestKakaoMeUrl = 'https://kapi.kakao.com/v2/user/me?secure_resource=true'
const requestNaverMeUrl = 'https://openapi.naver.com/v1/nid/me'
function createFirebaseToken(provider, accessToken) {
    return (function() {
        if(provider === 'KAKAO') {
            console.log('Requesting user profile from Kakao API server.')
            return request({
                method: 'GET',
                headers: {'Authorization': 'Bearer ' + accessToken},
                url: requestKakaoMeUrl,
            }).then((response) => {
                console.log("RequestMe : ", response);
                
                const body = JSON.parse(response)
                console.log(body)
        
                const userId = `kakao:${body.id}`
                if (!userId) {
                    return res.status(404)
                    .send({message: 'There was no user with the given access token.'})
                }
                let nickname = null
                let profileImage = null
                if (body.properties) {
                    nickname = body.properties.nickname
                    profileImage = body.properties.profile_image
                }
                let accountEmail = null;
                if(body.kakao_account) {
                    accountEmail = body.kakao_account.email;
                    console.log("Email", accountEmail);
                }
                return updateOrCreateUser(userId, accountEmail, nickname, profileImage, provider)
            })
        } else if(provider === 'NAVER') {
            console.log('Requesting user profile from Naver API server.')
            return request({
                method: 'GET',
                headers: {'Authorization': 'Bearer ' + accessToken},
                url: requestNaverMeUrl,
            }).then((response) => {
                console.log("RequestMe : ", response);
                
                const body = JSON.parse(response)
                console.log(body)
        
                if(body.resultcode === '00') {
                    const profile = body.response;
                    const userId = `naver:${profile.id}`
                    if (!userId) {
                        return res.status(404)
                        .send({message: 'There was no user with the given access token.'})
                    }
                    let displayName = profile.name
                    let profileImage = profile.profile_image
                    let accountEmail = profile.email;
                    console.log("Email", accountEmail);

                    return updateOrCreateUser(userId, accountEmail, displayName, profileImage, provider)
                } else {
                    throw new Error("Request Me Failed.")
                }
            })
        } else {
            throw new Error("Bad request");
        }
    })().then((userRecord) => {
        const userId = userRecord.uid
        console.log(`creating a custom firebase token based on uid ${userId}`)
        return admin.auth().createCustomToken(userId, {provider: provider})
    }).catch((error) => {
        console.log('Error createFirebaseToken', error);
        throw error;
    });
}

const requestKakaoTokenUrl = 'https://kauth.kakao.com/oauth/token'

// exports.kakaoCustomAuth = functions.region('asia-northeast1').https
exports.KakaoAuth = functions.https.onRequest((req, res) => {
    // console.log("Kakao Request:", req);
    try {
        if(req.method === 'POST') {
            let kakaoToken = null;
            let firebaseToken = null;

            const authCode = req.body.data.code;
            console.log("Kakao Auth Code:", authCode);
            if (!authCode) {
                return cors(req, res, () => {
                    res
                    .status(400)
                    .json({error: 'There is no token.', message: 'Access token is a required parameter.'});
                });
            }

            console.log(`Verifying Kakao Auth Code: ${authCode}`);
    
            (function(){
                console.log('Requesting user access token from Kakao API server.');

                return request({
                    method: 'POST',
                    headers: {'Content-type': 'application/x-www-form-urlencoded;charset=utf-8'},
                    url: requestKakaoTokenUrl,
                    form: {
                        grant_type: 'authorization_code',
                        client_id: process.env.KAKAO_APP_KEY_REST,
                        redirect_uri: process.env.KAKAO_APP_REDIRECT_URI,
                        code: authCode,
                        // client_secret: ''
                    }
                })
            })().then((response) => {
                console.log(response);

                const body = JSON.parse(response)
                console.log(body);

                kakaoToken = body.access_token;
                console.log("Kakao Access Token:", kakaoToken);
                // console.log(`Verifying Kakao token: ${kakaoToken}`);
                return createFirebaseToken('KAKAO', kakaoToken);
            }).then((fireToken) => {
                firebaseToken = fireToken;
                console.log(`Returning firebase token to user: ${fireToken}`);
                
                return cors(req, res, () => {
                    return res.status(200).json({
                        data : {
                            kakao_token: kakaoToken,
                            firebase_token: firebaseToken
                        }
                    });
                });
            }).catch((error) => {
                console.log(error);
                // console.log(error.body);
                return cors(req, res, () => {
                    if(error.error) {
                        const body = JSON.parse(error.error);
                        res.status(error.statusCode).json({
                            error : {
                                status: error.statusCode,
                                message: body.error,
                                details: body.error_description
                            }
                        });
                    } else {
                        res.status(500).json({error: 'Error'});
                    }
                });
            });
        } else {
            return cors(req, res, () => {
                res.json({});
            });
        }
    } catch(error) {
        console.log(error);
    }
});


const requestNaverTokenUrl = 'https://nid.naver.com/oauth2.0/token'
exports.NaverAuth = functions.https.onRequest((req, res) => {
    // console.log("Kakao Request:", req);
    try {
        if(req.method === 'POST') {
            let naverToken = null;
            let firebaseToken = null;

            const authCode = req.body.data.code;
            console.log("Naver Auth Code:", authCode);
            if (!authCode) {
                return cors(req, res, () => {
                    res
                    .status(400)
                    .json({error: 'There is no token.', message: 'Access token is a required parameter.'});
                });
            }

            console.log(`Verifying Naver Auth Code: ${authCode}`);

            const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
            (function(){
                console.log('Requesting user access token from Naver API server.');
                
                return request({
                    method: 'POST',
                    headers: {'Content-type': 'application/x-www-form-urlencoded;charset=utf-8'},
                    url: requestNaverTokenUrl,
                    form: {
                        grant_type: 'authorization_code',
                        client_id: process.env.NAVER_APP_CLIENT_ID,
                        client_secret: process.env.NAVER_APP_CLIENT_SECRET,
                        code: authCode,
                        state: state,
                    }
                })
            })().then((response) => {
                console.log(response);

                const body = JSON.parse(response)
                console.log(body);

                naverToken = body.access_token;
                console.log("Naver Access Token:", naverToken);
                return createFirebaseToken("NAVER", naverToken);
            }).then((fireToken) => {
                firebaseToken = fireToken;
                console.log(`Returning firebase token to user: ${fireToken}`);
                
                return cors(req, res, () => {
                    return res.status(200).json({
                        data : {
                            naver_token: naverToken,
                            firebase_token: firebaseToken
                        }
                    });
                });
            }).catch((error) => {
                console.log(error);
                // console.log(error.body);
                return cors(req, res, () => {
                    if(error.error) {
                        const body = JSON.parse(error.error);
                        res.status(error.statusCode).json({
                            error : {
                                status: error.statusCode,
                                message: body.error,
                                details: body.error_description
                            }
                        });
                    } else {
                        res.status(500).json({error: 'Error'});
                    }
                });
            });
        } else {
            return cors(req, res, () => {
                res.json({});
            });
        }
    } catch(error) {
        console.log(error);
    }
});

로그인 테스트

로그인 버튼을 눌러 아래의 화면이 나타나는지 확인합니다. 네이버 SDK에서 등록된 페이지가 아닐 경우에는 오류가 나타납니다. 이럴 경우 [네이버 애플리케이션 설정 > API 설정]으로 이동한 다음 서비스 페이지와 콜백 주소 등을 추가합니다.

네이버 로그인

웹 브라우저에서 토큰 및 파이어 베이스 사용자 정보를 확인합니다.

토큰 및 파이어베이스 사용자 정보

 

참고 자료

네이버 개발자 센터 - NAVER Developers

https://developers.naver.com/main/

네이버 아이디로 로그인 API 명세

https://developers.naver.com/docs/login/api/

 

소스코드

본 포스트 관련 소스코드는 여기에서 다운로드 가능합니다.

 

728x90