프로젝트/풀스택 프로젝트

5-3. [React + Express + MongoDB] Perfect Boiler - SNS 로그인 : passport 를 이용한 카카오, 구글, 네이버, 로그인과 JWT RefreshToken, AccessToken 사용하기

찰리-누나 2023. 1. 27.

 

 

passport-kakao를 사용하면 카카오 전략을 구현하여, 카카오 로그인을 할 수 있다. passport-kakao는 내부적으로 session에 생성된 req.login을 호출하는데, 자동으로 호출되는 req.login은 passport의 기존 session 방식을 사용하기 때문에, 내가 직접 구현한 JWT 로직을 사용할 수 없었다. 이 때문인지 세상 모든 블로그에 세션을 이용한 로그인만 있길래 모험삼아 처음부터 직접 구현해보았다. req.login을 직접 호출하고 세션을 사용하지 않음을 설정한 뒤에, req.login을 로컬 로그인처럼 직접 커스텀하면 passport-kakao를 사용하더라도 세션을 사용하지 않고 JWT와 쿠키를 이용한 로그인을 구현할 수 있다. 

 


 

 

 

passport-kakao 를 포함한 sns 로그인 라이브러리들을 설치해준다. 본문에서는 kakao 로그인만을 설명하지만, naver과 google을 임포트해주기만 하면 코드는 그대로 사용할 수 있다.

npm install passport-kakao passport-google-oauth20 passport-naver-v2

 

 

 

 

진행하기에 앞서, 카카오 디벨로퍼 사이트에서 카카오 로그인 api를 사용하기 위한 사전 설정을 해야 한다. 내 애플리케이션을 등록해주고, 앱 키의 REST API 키를 복사해준다.

 

 

 

또한 카카오 로그인에서 활성화 설정과 OpenID Connect 활성화 설정을 모두 ON으로 바꾸어 주었다.

 

 

Redirect URI는 '우리가 로그인 진행 해 줄게, 응답 값은 어디로 받을래?' 에 대한 답을 적는 곳이다. 나는 /kakao/oauth 라는 라우터로 받을 것이므로 해당 주소를 입력해 주었다.

 

 

 

동의 항목에서는 내가 사용자에게 어떤 정보를 전달받을지를 선택할 수 있는데, 이메일을 입력 받기 위해 [설정] 버튼을 클릭한 후 오른쪽과 같이 '카카오 계정으로 정보 수집 후 제공' 을 체크했다.

 

 

 

 

 

 

 

설정을 마쳤으니 로그인 과정을 살펴보자. 카카오 로그인은 내가 만든 입력 폼에 로그인 정보를 입력하는 것이 아닌, "카카오가 제공해주는 별도의 페이지" 에서 진행된다.

카카오가 제공해 주는 로그인 페이지

 

 

 

 

따라서 카카오 로그인을 구현하려면, 카카오 로그인 버튼을 클릭했을 때 카카오가 제공해주는 로그인 페이지로 이동되도록 만들어야 한다. '로그인 페이지로 이동되도록' 하는 기능은 백엔드의 /kakao 라우터가 담당하도록 해 줄 것이기 때문에, 아래와 같이 구현해 주었다.

 

 


카카오 로그인 버튼 클릭 -> 프론트에서 백엔드로 /kakao 요청 -> 카카오가 제공해주는 로그인 페이지로 이동

 

 

 

onClick을 이용해 /kakao 를 불러오는 처리를 하였더니 아래와 같은 CORS 오류를 만났는데,

Access to XMLHttpRequest at ‘https://kauth.kakao.com/oauth/authorize?response_type=code&redirect_uri={my_redirect_url}&client_id={my_client_id} 2’ (redirected from '어쩌구도메인') from origin ‘...:3000’ has been blocked by CORS policy: Response to preflight request doesn’t pass access control check: The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

 

 

 

 

카카오 데브톡을 서치해보니 인가 요청은 XMLHttpRequest가 아닌 페이지 이동을 해야 한다는 카카오측의 답변이 달려 있었다.

카카오의 답변, 커뮤니티가 굉장히 잘 되어 있었다.

 

 

이를 위해서는 a 태그 또는 Link 태그를 사용해야 한다. 나는 img src를 이미 구현해놓은 상태이므로 해당 태그를 a 태그로 감싸 준 뒤 백엔드의 /kakao 라우터 요청을 할 수 있도록 만들어 주었다. 

...

let redirectURL = `${process.env.REACT_APP_API_USER}/kakao`;

...


<span>
                <a href={redirectURL}>
                  <img src={Kakao} />
                </a>
              </span>

 

 

 

 

 

kakaoStrategy.js 

 

passport 폴더에 kakaoStrategy.js 파일을 만들고, 카카오 로그인 전략(로직)을 작성한다. 단계는 아래와 같이 해 줄 것이다.카카오 로그인의 경우, 별도의 회원가입 절차 없이 ' 처음 로그인을 시도할 경우 회원가입을 시킨 뒤 로그인하게 해주고, 아니면 바로 로그인을 시킨다 ' 는 절차가 적용된다. 따라서

 

 

1. 카카오로 회원가입 유저일 경우 => 바로 로그인

2. 카카오로 회원가입 하지 않은 유저일 경우 => 회원가입 시킨 뒤 => 바로 로그인 로직으로 이동시켜 주어 로그인까지 처리

 

 

라는 단계를 거쳐야 한다. 유저가 저장되 User 모델 (테이블)에는 아이디, 닉네임, 이메일, provider를 저장하는 필드가 있으며 provider로컬로 회원가입 한 유저인지, sns로 회원가입 한 유저인지를 나타낸다. 해당 필드는 String 타입이며 default 값은 "local" 이다. 카카오 로그인을 시도하는 유저는 "kakao" 라는 값으로 가입되도록 할 것이다.

// User 모델

provider: {
    type: String,
    default: "local",
  },

 

 

 

순서는 이렇다. 프론트에서 유저가 /kakao 라우터를 요청하면, /kakao 라우터는 kakaoStrategy으로 가서 카카오 로그인을 위해 '유저의 가입 여부' 를 검사하고, done 함수로 req.login에 필요한 파라미터를 보내준다. done 함수는 /kakao/oauth 에 있는 req.login을 실행하는데 사용되며, 그곳에서 실제 로그인이 진행된다. 

 

 

 

 


/kakao -> kakaoStrategy.js -> /kakao/oauth -> 로그인 성공/실패


 

 

 

 

먼저 설치한 라이브러리들을 불러와 주고, clientID와 callbackURL을 설정해준다.

 

● clientID는 카카오 디벨로퍼 페이지에서 발급받은 REST API 키 값이고

 callbackURL은 로그인 과정이 끝난 뒤 카카오 측으로부터 결과를 전송받을 url (카카오 페이지에서 /oauth를 사용할 것을 권장하고 있기 때문에 /oauth로 설정함)

const passport = require("passport");
const KakaoStrategy = require("passport-kakao").Strategy;

const { User } = require("../models/User.js");

module.exports = () => {
  passport.use(
    new KakaoStrategy(
      {
        clientID: process.env.KAKAO_ID,
        // "http://localhost:8080/api/user/kakao/oauth ",
        callbackURL: `${process.env.BACKEND_URL}/api/user/kakao/oauth`,
      },

 

 

 

 

 

로그인에 성공하면, 카카오는 로그인을 요청한 웹사이트에게 accessToken, refreshToken, profile을 제공해준다. 이 중 profile에 로그인을 시도하는 유저의 정보가 들어있다. 때문에 profile은 콘솔로 출력해본 뒤 원하는 값을 뽑아 사용하는게 좋다.

 

내가 사용했을때는 profile.username과 displayName에 닉네임이 들어있었고, _json으로 시작하는 키:값 쌍의 오브젝트의 kakao_account.email 에 이메일 정보가 들어있었다. 카카오가 업데이트 할때마다 달라지는 것 같아서 개발할 때마다 출력해보고 사용하는게 좋을 것 같았다.

 

 

 

이제 카카오 측에서 제공해준 profile을 이용해 가입한 유저가 있을 경우와 없을 경우를 처리해주면 된다. 먼저 있을 경우에는, done을 통해 req.login에 에러가 들어가는 첫번째 인자값에는 null값을, 찾은 user가 들어가는 두번째 값에는 찾아낸 유저의 값을 전달해 준다.

async (accessToken, refreshToken, profile, done) => {
        try {
          //가입한 유저를 찾는데,
          let exUser = await User.findOne({
            id: profile._json.kakao_account.email,
            provider: "kakao",
          });
          if (exUser) {
            //가입한 유저가 있을 경우
            console.log("가입한 유저임, id: ", exUser.id);
            //done 호출
            done(null, exUser);
          }

 

 

만일 가입한 유저가 없을 경우에는 회원가입과 로그인을 순차적으로 처리해 준다. profile에 들어있는 정보를 이용해 save 함수를 통하여 DB에 저장해주고, done을 호출해 req.login으로 이동한다. 이때 반드시 authNum을 통해 "랜덤 숫자열" 을 만들고, 닉네임 뒤에 붙여주어야 한다. 이것은 기본키로 되어있는 닉네임이 로컬 회원가입, 또는 다른 sns 로그인을 통해 회원가입한 유저의 닉네임과 겹쳤을 경우를 위한 예외 처리의 일종이다. 만일 로그인 과정을 진행하지 못하고 오류가 발생하면 catch문을 이용하면 된다.

 else {
            //가입한 유저가 없을 경우
            console.log("가입한 유저 없음, 가입시켜줘야함 : ", profile._json);
             let authNum = Math.random().toString().substr(2, 6);
            exUser = new User({
              id: profile._json.kakao_account.email,
              email: profile._json.kakao_account.email,
              nikname: profile.displayName + authNum,
              provider: "kakao",
              image: profile._json.properties.profile_image,
            });
            exUser.save((err, userInfo) => {
              if (err) {
                console.log("카카오 유저 회원가입 중 에러 : ", err);
                console.log("에러난 회원 정보:", userInfo);
              } else {
                console.log("카카오 회원 회원가입 성공", exUser);
              }
            });
            done(null, exUser); //회원가입 후 로그인 시킴
          }
        } catch (error) {
          console.log("카카오 로그인 또는 회원가입 못하고 에러", error);
          done(error);
        }

 

 

 

 

전체 코드는 아래와 같다.

더보기

 

const passport = require("passport");
const KakaoStrategy = require("passport-kakao").Strategy;

const { User } = require("../models/User.js");

module.exports = () => {
  passport.use(
    new KakaoStrategy(
      {
        clientID: process.env.KAKAO_ID,
        // "http://localhost:8080/api/user/kakao/oauth ",
        callbackURL: `${process.env.BACKEND_URL}/api/user/kakao/oauth`,
      },
      /*
       * clientID에 카카오 앱 아이디 추가
       * callbackURL: 카카오 로그인 후 카카오가 결과를 전송해줄 URL
       * accessToken, refreshToken: 로그인 성공 후 카카오가 보내준 토큰
       * profile: 카카오가 보내준 유저 정보. profile의 정보를 바탕으로 회원가입
       */
      async (accessToken, refreshToken, profile, done) => {
        try {
          //가입한 유저를 찾는데,
          let exUser = await User.findOne({
            id: profile._json.kakao_account.email,
            provider: "kakao",
          });
          if (exUser) {
            //가입한 유저가 있을 경우
            console.log("가입한 유저임, id: ", exUser.id);
            //done 호출
            done(null, exUser);
          } else {
            //가입한 유저가 없을 경우
            console.log("가입한 유저 없음, 가입시켜줘야함 : ", profile._json);
            exUser = new User({
              id: profile._json.kakao_account.email,
              email: profile._json.kakao_account.email,
              nikname: profile.displayName,
              provider: "kakao",
            });
            exUser.save((err, userInfo) => {
              if (err) {
                console.log("카카오 유저 회원가입 중 에러 : ", err);
                console.log("에러난 회원 정보:", userInfo);
              } else {
                console.log("카카오 회원 회원가입 성공", exUser);
              }
            });
            done(null, exUser); //회원가입 후 로그인 시킴
          }
        } catch (error) {
          console.log("카카오 로그인 또는 회원가입 못하고 에러", error);
          done(error);
        }
      }
    )
  );
};

 

 

 

 

 

 

 

 

users.js

이제 users.js에서 카카오 로그인 라우터를 작성하면 거의 끝난다.

 

먼저 /kakao 를 작성해준다. 프론트에서 버튼을 클릭해 /kakao api를 호출하면, kakao 전략, 즉 kakaoStrategy.js 에 있는 내용들이 실행된다. 나는 세션을 이용하지 않으므로, session : false라는 값을 설정해 주었다.

router.get("/kakao", passport.authenticate("kakao", { session: false }));

 

 

 

 

위에서 작성한 kakaoStrategy.js 는 done을 통하여 callbackURL, 즉 /kakao/oauth에 있는 req.login을 실행한다. 따라서 /kakao/oauth 라우터를 추가로 작성해 주어야 한다. done함수를 통해 받은 내용들이 err, user, info에 들어있다. if문을 사용해 에러가 날 경우 에러를 콘솔로 출력한다.

router.get("/kakao/oauth", (req, res, next) => { 
  passport.authenticate("kakao", (err, user, info) => {
     if (err) {
      console.log("카카오 로그인 실패 에러 ", err);
    }

 

 

 

 

그 뒤 에러 없이 kakaoStrategy가 정상 작동하여, done을 받고 돌아왔을 때 실행될 req.login을 직접 작성해주면 된다. 보통은 세션에 저장되게 하기 위해 바로 res.redirect를 쓰는데, 나는 세션을 사용하지 않고 직접 만든 JWT 로직을 사용할 것이므로 req.login을 커스텀 해 주었다.

로컬 로그인 로직과 완벽히 같으므로 토큰 생성과 쿠키 생성 부분을 함수로 작성해 주었는데, 블로그 글을 쓰기 위해 펼쳐서 한번 써보았다.

 

req.login에 session:false를 주고, 로그인에 성공할 경우 로컬 로그인과 똑같이 엑세스 토큰과 리프레시 토큰을 발급해 주면 된다.

 else {
      console.log("카카오 로그인하는 유저", user);
      return req.login(user, { session: false }, (loginError) => {
        if (loginError) {
          console.log("loginError", loginError);
          return next(loginError);
        }

        // 로그인에 성공했을 경우
        // 여기서 JWT 생성

        // jwt생성하는 미들웨어 사용

        const accessToken = generateToken("accessToken", user._id);
        const refreshToken = generateToken("refreshToken", user._id);

        user.refreshToken = refreshToken;

        // DB에 리프레시 토큰을 저장한다.
        user.save((err, user) => {
          if (err) {
            console.log("토큰 저장 못하고 에러남");
          } else {
            console.log("토큰 저장함", user);
          }
        });

        // 엑세스 토큰을 쿠키에 저장한다.
        // 1000*60=1분
        // 1000 * 60 * 30 30분생존
        res.cookie("accessToken", accessToken, {
          httpOnly: true,
          maxAge: 1000 * 60 * 30,
          // sameSite: "none",
          secure: false,
        });

        res.cookie("refreshToken", refreshToken, {
          httpOnly: true,
          maxAge: 1000 * 60 * 60 * 24 * 14,
          // sameSite: "none",
          secure: false,
        });

        return res.redirect(process.env.FRONT_URL);
      });
    }
  })(req, res, next);
});

 

 

 

 

 

전체 코드는 아래와 같다.

 

더보기

 

router.get("/kakao", passport.authenticate("kakao", { session: false }));

router.get("/kakao/oauth", (req, res, next) => {
  passport.authenticate("kakao", (err, user, info) => {
    if (err) {
      console.log("카카오 로그인 실패 에러 ", err);
    } else {
      console.log("카카오 로그인하는 유저", user);
      return req.login(user, { session: false }, (loginError) => {
        if (loginError) {
          console.log("loginError", loginError);
          return next(loginError);
        }

        // 로그인에 성공했을 경우
        // 여기서 JWT 생성

        // jwt생성하는 미들웨어 사용

        const accessToken = generateToken("accessToken", user._id);
        const refreshToken = generateToken("refreshToken", user._id);

        user.refreshToken = refreshToken;

        // DB에 리프레시 토큰을 저장한다.
        user.save((err, user) => {
          if (err) {
            console.log("토큰 저장 못하고 에러남");
          } else {
            console.log("토큰 저장함", user);
          }
        });

        // 엑세스 토큰을 쿠키에 저장한다.
        // 1000*60=1분
        // 1000 * 60 * 30 30분생존
        res.cookie("accessToken", accessToken, {
          httpOnly: true,
          maxAge: 1000 * 60 * 30,
          // sameSite: "none",
          secure: false,
        });

        res.cookie("refreshToken", refreshToken, {
          httpOnly: true,
          maxAge: 1000 * 60 * 60 * 24 * 14,
          // sameSite: "none",
          secure: false,
        });

        return res.redirect(process.env.FRONT_URL);
      });
    }
  })(req, res, next);
});

 

 

 

 

 

 

 

passport/index.js

이제 passport 폴더의 index.js에 내가 만든 카카오 전략을 등록해 주기만 하면 passport-kakao 와 JWT 의 accessToken, refreshToken 을 이용한 로그인이 끝난다. 엑세스 토큰, 리프레시 토큰은 지난번 작성한 auth 미들웨어가 알아서 진행해 주므로, 로그인 상태를 인증하는 라우터를 추가로 작성할 필요가 없다. (짱좋음)

const passport = require("passport");
const local = require("./localStrategy");
const kakao = require("./kakoStrategy");

module.exports = () => {
  local();
  kakao();
};

 

 

 

 

언제나 과정을 제대로 마치면 배포하고 있는 상태이기 때문에, 배포 사이트에서 확인할 수 있다. (네이버는 어플리케이션 검수가 필요하기 때문에 구글과 카카오 로그인만 배포에 적용함)

: https://web-boiler-frontend-1jx7m2gld43p7bv.gksl2.cloudtype.app/

카카오 로그인

 

 

 

 

 


 

 

카카오와 구글 로그인은 각자의 디벨로퍼 사이트에서 api 키를 발급받고, 연결해주는 것 말고는 거의 다른 부분이 없다. 있다면 이정도..

 

users.js에서 구글의 경우 스코프를 등록해 주어야 한다.

router.get(
  "/google",
  passport.authenticate("google", {
    session: false,
    scope: ["profile", "email"],
  })
);

 

네이버의 경우는 { authType: "reprompt" } 이 필요하다. 반드시 필요한 동의 항목도 선택하지 않고 넘어갈 수 있는 부작용이 있기 때문에, reprompt를 주어 필요한 항목을 제공받지 못했을 시에 다시 동의창을 띄워주는 옵션이다.

router.get("/naver", passport.authenticate("naver", { authType: "reprompt" }));

 

 

 

구글 로그인 진행
임의의 숫자열이 추가된 네이버 닉네임

 

 

 

 

네이버와 구글도 이렇게 구현이 잘 된 모습을 볼 수 있다. 카카오를 하나 만들고 나니, 세세한 옵션 말고는 로직이 완벽히 똑같아서 세개의 sns 로그인을 구현하는데 한시간도 채 걸리지 않았다.

 

그러나 네이버 로그인은 배포 시 어느 아이디로나 로그인이 가능하게 하려면 애플리케이션 검수 요청을 받아야 하는 불편함이 있다. 그래서 네이버는 구현했다는 뿌듯함만 안고 애플리케이션 검수 요청은 하지 않기로 하고, 배포한 곳에는 구글과 카카오 로그인만 가능하게 하였다.

댓글