본문 바로가기

프로젝트/미니 프로젝트

express + react 를 활용한 OAuth 소셜 회원가입및 로그인 진행하기 (3) express 셋팅 및 Oauth 회원가입및 로그인 설정하기

반응형

https://loy124.tistory.com/383

https://loy124.tistory.com/384    

 

 

 

https://github.com/loy124/express-react-oauth-login

 

GitHub - loy124/express-react-oauth-login: express 와 react를 활용해 만드는 kakao및 google 회원가입/로그인

express 와 react를 활용해 만드는 kakao및 google 회원가입/로그인. Contribute to loy124/express-react-oauth-login development by creating an account on GitHub.

github.com

코드

이제 express 를 활용한 백엔드 서버를 제작할 때가 왔다.

 

주요 회원가입/로그인 방식 정리

회원가입 

Oauth 요청 받음 -> kakao/구글에서 OAuth redirect -> 해당 kakao/ 구글 Oauth서버에서 승인후 redirect Uri에 redirect -> redirect시 받아온 code를 활용해 accessToken을 발급 -> 해당 accessToken을 활용해 이메일 정보및 고유 sns_id 받아오기  -> 위 값을 기반으로 회원가입 처리 -> 로그인 

로그인

DB내에 sns_id가 존재하는경우 user_id를 리턴 -> 해당 user_id를 기반으로 refresh 토큰 발급후 redirect할 front에 cookie로 저장 -> 프론트에 redirect 되면 -> useEffect 를 활용해 로딩 되자마자 silent-refresh 요청 -> 해당 프론트가 가지고 있던 refreshToken을 활용해 accessToken에 발급 -> 해당 accessToken을 Authorization header에 넣어서 기존 상태로 유지 -> 반복 

 

위 로직 을 생각할 때 어찌 짜야 할지 라는 고민을 정말 많이한거 같다. 

 

 

먼저 routes 폴더 db 폴더 utils 폴더를 생성한다.

 

routes 폴더 - express 라우팅 관련

db 폴더 - db 관련 접근 관련 

utils 폴더 - 주요 라이브러리 모음 

 

 

 

Oauth에 대한 요청이 승인이 되었을때 사용하게 될 로그인 인증 방식으로 

jsonwebtoken을  사용한다.

jwt에 대한 간단한 설명은 하단에 기재해 두었다. 

https://loy124.tistory.com/300

 

Stateful / Stateless / JWT

Stateful  서버는 클라이언트에서 요청을 받을 때 마다 클라이언트의 상태를 계속해서 유지하고 이 정보를 서비스 제공에 이용(세션에 로그인이 되어있다고 저장을 해두고 서비스를 제공할때 그

loy124.tistory.com

 

utils, db 폴더 생성및 정리하기 

 

utils에는 jwt 토큰에 대한 라이브러리를 모아두었다.

 

 

 

utils/jwt.js 

const jwt = require('jsonwebtoken');

const verifyToken = (token) => {
    try {
        const decoded = jwt.verify(token, "PASSWORD")
        return decoded;
    } catch (error) {
        // TokenExpiredError
        // 기간 만료 

        // JsonWebTokenError
        // 서명이 유효하지 않거나 수정된 경우 

        // NotBeforeError 
        // jwt형식이 아닌경우  
 
        if(error.name === 'TokenExpiredError'){
            
        }
        if(error.name === 'JsonWebTokenError'){
            console.log(error);
        }
        if(error.name === 'NotBeforeError'){
            console.log(error);
        }

        console.log(err)
        return false
    }   
}

// access 토큰 
// 유효기간 2시간
// 매 요청마다 로그인 수행 한다 -> cookie에 있는 거로 
const makeAccessToken = (id) => {
    try {
        return jwt.sign({
            id
        }, "PASSWORD", {
            expiresIn: '2h'
        })
    } catch (error) {
        
    }
}

// refresh 토큰 
// 유효기간 2주
const makeRefreshToken = (id) => {
    try {
        return jwt.sign({
            id
        }, "PASSWORD", {
            expiresIn: '14d'
        })
        
    } catch (error) {
        //  로그 남기기
        return "error"
    }
}

module.exports = {verifyToken , makeAccessToken, makeRefreshToken}

verifyToken : 토큰에 대한 유효성 검증 역할, 유효성이 검증되어야 다른 로직(로그인유지등)을 실행가능하다

makeAccesToken, makRefreshToken: jwt 토큰을 생성한다는 점에서는 같지만

accessToken의 유효기간은 2시간 refreshToken의 유효기간은 2주로 책정하였다. 

accessToken의 유효기간이 끝나더라도 refreshToken의 유효기간이 존재하면 다시 accessToken을 발급해서 주면 된다. 

 

 

db/user.js

const db = require('../../models');

const isExistSnsId = async(type, sns_id) => {
    try {
        const result =  await db['social_login'].findOne({
            where:{
                type,
                sns_id
            }
        });
    
        if(result['dataValues'].id){
            return result['dataValues'].id;
        }else{
            throw new Error();
        }
        
    } catch (error) {
        return false;
        
    }
}

const snsSignUp = async({email,nickname, sns_id,type }) => {
    const transaction = await db.sequelize.transaction();
try {

    if (!nickname){
        nickname = email.split("@")[0];
    }
    if(email && nickname){

        try {
            const user = await db['user'].create({
                email,
                nickname
            },{transaction});
            const social_login = await db['social_login'].create({
                sns_id,
                type,
                user_id: user['dataValues'].id
            },{transaction});  

            transaction.commit();
            
            return user['dataValues'].id;
        } catch (error) {
            console.log(error);
            transaction.rollback();
            return false
        }
    }
    
  } catch (error) {
    console.log(error);
    return false;
  }

}
module.exports ={snsSignUp, isExistSnsId}

isExistSnsId: Oauth를 통해 받아온 고유 id값 < -> DB 내에 저장된 고유 sns_id 값과 대조해서 있을경우 고유 id값 return, 없을경우 false를 리턴

snsSignUp: 회원가입 로직, transaction을 활용해서 모든 기능이 수행되어야 데이터베이스 저장될 수 있도록 하였다. 

 

 

 

이제 각 express설정및 routing 설정을 해주었다. 

 

index.js

const express = require('express');
const morgan = require('morgan');
const PORT = 8000;
const app = express();
const routes = require('./routes');
const cookieParser  = require('cookie-parser');
const cors = require('cors');

app.use(cors({
    origin:true,
    credentials:true
}));
app.use(express.urlencoded({extended:false}));
app.use(express.json());
app.use(cookieParser());
app.use(morgan('dev'));
app.use(routes);


app.listen(PORT, () => console.log(`this server listening on ${PORT}`))

 

 

routes/index.js

 

const users = require('./user');
const express = require('express');
const router = express.Router();

router.use("/user", users);

module.exports = router;

routes/index를 통해 route내 파일들을 구분해서 관리해줬다. 

 

routes/user/index.js

const express = require('express');
const router = express.Router();
const dotenv = require('dotenv');
dotenv.config();

const kakao = require('./kakao');
const google = require('./google');

router.use("/", kakao);
router.use("/", google);


module.exports = router;

routes/user/index를 통해 route의 user 파일들을 분리해서 관리해줬다. 

 

 

 

routes/user/kakao.js

const express = require('express');
const router = express.Router();
const dotenv = require('dotenv');
const axios = require('axios')
const {verifyToken, makeAccessToken, makeRefreshToken} = require('../../utils/jwt');
const { snsSignUp, isExistSnsId } = require('../../db/user');

dotenv.config();

const KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth"
const KAKAO_AUTH_REDIRECT_URL = "http://localhost:8000/user/auth/kakao/callback"
router.post("/auth/silent-refresh", (req, res, next) =>{
  const {refreshToken} = req.cookies;

  const verifyAccessToken = verifyToken(refreshToken);

  if(verifyAccessToken.id){
    // refresh Token 갱신 
    const accessToken = makeAccessToken(verifyAccessToken.id);
    const refreshToken = makeRefreshToken(verifyAccessToken.id);

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true
    });

    return res.json({accessToken})
    
  }
  
  return res.json({test:"Test"})
});

router.get("/auth/kakao", (req, res, next) => {

    return res.redirect(`${KAKAO_AUTH_URL}/authorize?client_id=${process.env.KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_AUTH_REDIRECT_URL}&response_type=code`)
})

router.get("/auth/kakao/callback", async(req, res, next) => {

    console.log(req.query);
    const {code} = req.query;
try{
  
  const {data} = await axios({
    method: 'POST',
    url: `${KAKAO_AUTH_URL}/token`,
    headers:{
        'content-type':'application/x-www-form-urlencoded;charset=utf-8'
    },
    params:{
      grant_type: 'authorization_code',//특정 스트링
      client_id:process.env.KAKAO_CLIENT_ID,
      client_secret:process.env.KAKAO_SECRET_ID,
      redirectUri:KAKAO_AUTH_REDIRECT_URL,
      code:code,
    }
  })
  const kakao_access_token = data['access_token'];
  
  
  const {data:me} = await axios({
    method: 'GET',
    url: `https://kapi.kakao.com/v2/user/me`,
    headers:{
        'authorization':`bearer ${kakao_access_token}`,
    }
  });
  const {id, kakao_account} = me;
  const userInformation = {
    email: kakao_account.email,
    nickname: kakao_account.profile.nickname,
    sns_id : id,
    type:'kakao',
  };


  const user_id = await isExistSnsId(userInformation.type, userInformation.sns_id);
  console.log(user_id)
  // id가 있는경우 가입이 된 상태이기 떄문에 로그인 로직으로 넘긴다
  if(user_id){
    const refreshToken = makeRefreshToken(user_id);
 
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true
    });


  }else{
    const signUpUserId= await snsSignUp(userInformation);
    // 가입 완료 후 바로 로그인 로직으로 넘겨서 로그인 되게끔 진행한다 
    const refreshToken = makeRefreshToken(signUpUserId);
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true
    });
  }
  

}
catch (error){
  console.log(error);
}

    return res.redirect("http://localhost:3000")
})

module.exports = router;

 

 

routes/user/google.js

const express = require('express');
const router = express.Router();
const dotenv = require('dotenv');
const axios = require('axios')
const {verifyToken, makeAccessToken, makeRefreshToken} = require('../../utils/jwt');
const { snsSignUp, isExistSnsId } = require('../../db/user');

dotenv.config();

const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
const GOOGLE_AUTH_TOKEN_URL= "https://oauth2.googleapis.com/token"
const GOOGLE_AUTH_REDIRECT_URL = "http://localhost:8000/user/auth/google/callback"
router.post("/auth/silent-refresh", (req, res, next) =>{
  const {refreshToken} = req.cookies;

  const verifyAccessToken = verifyToken(refreshToken);

  if(verifyAccessToken.id){
    // refresh Token 갱신 
    const accessToken = makeAccessToken(verifyAccessToken.id);
    const refreshToken = makeRefreshToken(verifyAccessToken.id);

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true
    });

    return res.json({accessToken})
    
  }
  
  return res.json({test:"Test"})
});

router.get("/auth/google", (req, res, next) => {
    
  // 해당 부분에서 github에 요청을 보낸다 
  return res.redirect(`${GOOGLE_AUTH_URL}?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${GOOGLE_AUTH_REDIRECT_URL}&response_type=code&include_granted_scopes=true&scope=https://www.googleapis.com/auth/userinfo.email`)
  // return res.json({"test":"test"})
})
router.get("/auth/google/callback", async(req, res, next) => {

    console.log(req.query);
    const {code} = req.query;
try{
  
    const {data} = await axios({
        method: 'POST',
        url: `${GOOGLE_AUTH_TOKEN_URL}`,
        headers:{
            'content-type':'application/x-www-form-urlencoded;charset=utf-8'
        },
        params:{
          grant_type: 'authorization_code',//특정 스트링
          client_id:process.env.GOOGLE_CLIENT_ID,
          client_secret:process.env.GOOGLE_SECRET_ID,
          redirectUri:GOOGLE_AUTH_REDIRECT_URL,
          code:code,
        }
      })

      const access_token = data['access_token'];
  
const {data:me} = await axios.get(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}`);
  const {sub, email, name} = me;
  const userInformation = {
    email: email,
    nickname:name,
    sns_id : sub,
    type:'google',
  };


  const user_id = await isExistSnsId(userInformation.type, userInformation.sns_id);

  // id가 있는경우 가입이 된 상태이기 떄문에 로그인 로직으로 넘긴다
  if(user_id){
    const accessToken = makeAccessToken(user_id);
    const refreshToken = makeRefreshToken(user_id);
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true
    });


  }else{
    const signUpUserId= await snsSignUp(userInformation);
    const refreshToken = makeRefreshToken(signUpUserId);
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true
    });

  }
  

}
catch (error){
  console.log(error);
}

    return res.redirect("http://localhost:3000")
})

module.exports = router;

 

auth/kakao/ , auth/google -> 해당 요청이 들어온 경우 각 SNS 별 OAuth 서버에 리다이렉트로 전송해준다

auth/kakao/callback , auth/google/callback 

Oauth 로부터 각자 리다이렉트되는 주소 (각 google, kakao에서 설정한 redirect URL과 일치해야한다.)

리다이렉트 되면서 code라는 값을 파라미터로 전송받게 되고 해당 code를 활용하면 각 OAuth의 access_token에 대해 발급이 가능하다 -> 이제 해당 access_token을 기반으로 email및 고유ID에 대한 정보를 수신받고 -> 고유 ID가 존재하지 않는다면 회원가입 처리 -> 고유 ID가 존재한다면 바로 로그인 처리로 넘긴다. 

 

auth/silent-refresh 

회원가입및 로그인 프로세스를 진행하면 해당 프론트단의 cookie에 httpOnly 옵션으로 refreshToken이 저장이된다. 

이제 해당 refreshToken을 기반으로 accessToken을 발급해준다. 

 

 

 

Frontend 설정하기 

프론트 파트는 단순 로그인이 되는지를 확인하기 위한 정도로만 사용할 예정이다. 

 

먼저 create-react-app 을 활용해 react app을 생성해준다. 

 

npx create-react-app .

.을 활용하게 되면 현재 경로로 다운로드가 가능하다. 

 

추가적으로 모듈 두개를 더 다운받아준다.

npm i react-router-dom axios

 

이제 해당 React에서 Login 요청을 보낼 수 있는 A태그 가있는 Login component를 생성한다

 

src/Login.js

import Container from './Container';

function Login() {
  return (
    <Container>
     <a href="http://localhost:8000/user/auth/kakao">카카오로 로그인하기</a>
     <a href="http://localhost:8000/user/auth/google">구글로 로그인하기</a>
  
    </Container>
  );
}

export default Login;

 

 

 

App.js

import './App.css';
import {BrowserRouter as Router, Switch, Route} from "react-router-dom"
import About from './About';
import Login from './Login';
import Main from './Main';
import { useEffect, useState } from 'react';
import axios from 'axios';
function App() {

const [isLoggedIn, setIsLoggedIn] = useState(false)

  useEffect(() =>{
    
    axios.post('http://localhost:3000/user/auth/silent-refresh',{}, {
      withCredentials:true
    }).then(res=> {
      console.log(res);
      const {accessToken} = res.data;
      console.log(accessToken);
      axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
      setIsLoggedIn(true)
    });


  },[])
  return (
    <>
    {isLoggedIn && <nav >로그인 되었습니다.</nav>}
    
      <Router>
        <Switch>
          <Route path="/" exact component={Main}></Route>
          <Route path="/about" component={About}></Route>
          <Route path="/login" component={Login}></Route>
        </Switch>
      </Router>
    </>
  );
}

export default App;

 

여기까지 작성하면 모든 구현은 완료된다.

 

 

테스트하기 

양쪽의 서버를 기동 시켜준다

 

기동 후 React 서버에 접근해서 

 

구글로 로그인하기 클릭 

 

 

카카오로 로그인하기 클릭

 

application/cookie

 

위와같이 express + react를 활용해 회원가입및 로그인 시스템을 구축해보는 작업을 해보았다. 

 

 

후기

회원가입 flow를 주말마다 고민해서 6일 정도 고민했고 정작 코드로 옮기는건 얼마 걸리지 않았던거같다.

다시한번 설계에 대한 중요성과 이를 통한  FLOW를 보는것이 얼마나 중요한것인지 깨달을 수 있는 좋은계기라고 생각한다.

추후 시간이 되면 해당 내용은 다시한번 정리하고싶다. 

반응형