[TIL] OAuth, 깃허브 로그인 구현

ansmeer008·2022년 11월 14일
1

Today I learn

목록 보기
53/65
post-thumbnail

What is OAuth ?

인증을 중개해주는 매커니즘으로, 보안된 리소스에 액세스 하기 위해 클라이언트에게 제공하는 프로세스를 단순화하는 프로토콜

(ex: 소셜 로그인 - 카카오/네이버로 간편 로그인 등)

  • 자주 사용하고 중요한 서비스들의 ID와 Password만 기억하면 이를 통해 외부 서비스에도 소셜 로그인이 가능.
  • 검증되지 않은 앱에서 OAuth 사용해 로그인 할 때 직접 유저의 민감한 정보가 노출될 일이 없고, 인증 권한에 대한 허가를 미리 유저에게 구해야 하기 때문에 안전하게 사용 가능.




OAuth 인증 흐름

Authorization Code Grant Type

: Authorization Code를 받아 이 코드로 액세스 토큰을 받는 방식으로 액세스 토큰이 사용자나 브라우저에 표시되지 않으므로 누출될 위험이 적다.

Refresh Token Grant Type

  • Authorization Code Grant Type 로 액세스 토큰 발급받은 후 액세스 토큰이 만료된 경우 리프레시 토큰을 활용해 새로운 액세스 토큰으로 교환하는 데 사용됨.
    (로컬 서버가 리프레시 토큰으로 Auth 서버에 새로운 액세스 토큰을 요청하고, 전달받으면 전달 받은 액세스 토큰 이융해 리소스 서버에서 유저 정보를 받고, 유저 정보를 클라이언트에게 전달)
  • 사용자와의 추가적 상호작용 없이 계속 유효한 액세스 토큰 가질 수 있음.




🍂 OAuth 과제 : 깃허브 로그인 구현하기

🟠 목표

: 외부 인증 서버(Github)에게 Access Token을 받아오고 다시 유저의 인증 정보를 요청하는 플로우를 구현 (클라이언트 파트 구현)

🟠 Bare minimum requirement

: Login with Github 버튼을 클릭했을 때, 깃허브에서 인증이 성공하면 Mypage에서 내 유저 정보를 확인할 수 있어야 함.

🟠 구현 내용

< Github에 내 앱 등록 >

깃허브에서 OAuth앱 등록하기

Homepage URL, Authorization callbak URL을 과제 디렉션에 따라 과제 클라이언트 주소 https://localhost:3000으로 리디렉션 해준다.

Authorization callback URL은 인증 과정이 끝난 뒤 (깃허브에서) 리디렉션을 통해서 다시 내 어플리케이션으로 이동하기 위해 필요한 URL이다.

앱을 등록하고 나면 Client ID와 Client Secret을 제공 받을 수 있는데 특히 Client Secret은 항상 비밀로 지켜져야 해서 환경 변수로 넣어주기로 했다. 환경 변수는 gitignore 파일에 .env를 추가함으로써 다른 곳에 올리는 과정에서 제외될 수 있도록 한다.




< 구현 >

1. 로그인 버튼 클릭 => 깃허브 Authorization Server에서 권한 허락 => Redirect & Authorization code 클라이언트에 전달

클라이언트 페이지에서 Login with Github 버튼을 클릭하면 Github의 Auth 서버에 연결된다.

여기서 클라이언트 아이디 자리에 들어갈, 우리가 깃허브에서 OAuth 앱을 등록하면서 받은 Client ID는 클라이언트 파일에서도 환경 변수로 설정되어 있는데, 리액트에서는 환경 변수를 사용하기 위해서 'REACT_APP_CLIENT_ID'처럼 변수 이름을 설정해주어야 했다.
그리고 불러 올 때도 아래처럼 process.env 외에 REACT_APP을 붙여준다.

아이디 앞의 깃허브 주소는 깃허브에 유저의 깃허브 신원을 요구하기 위해서 (발 번역...) 사용하는 주소이다.
https://github.com/login/oauth/authorize 뒤에 입력되는 client_id는 필수로 입력해야 하는 파라미터이고, 그 외 redirect_uri나 state, login, allow_signup 등의 파라미터들이 있다. 참고

//Login 함수 내부 

const CLIENT_ID = process.env.REACT_APP_CLIENT_ID;

const loginRequestHandler = () => {
    // TODO: GitHub로부터 사용자 인증을 위해 GitHub로 이동해야 합니다. 적절한 URL을 입력하세요.
    // OAuth 인증이 완료되면 authorization code와 함께 callback url로 리디렉션 합니다.
    return window.location.assign(
      `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}`
    ); 
    //로그인 요청을 보내면 github auth server에서 redirect 로 callback, 그리고 auth code를 전달
  };



2. authorization code를 이용해 access token을 발급받기 위한 post 요청 보냄 (로컬 서버에서 github의 Auth server로) => 발급 받은 access Token을 App.js 속의 state에 저장 => Mypage 컴포넌트에서 props로 받는다

클라이언트의 App.js 파일에 있는 getAcessToken은 authorizationCode를 전달인자로 받아 callback이라는 엔드포인트를 붙인 url로 이동해 액세스 토큰을 받고, isLogin이라는 상태를 true로 만드는 작업을 해준다.

//Client/App.js 파일 

const getAccessToken = async (authorizationCode) => {
    // Access Token은 보안 유지가 필요하기 때문에 클라이언트에서 직접 OAuth App에 요청을 하는 방법은 보안에 취약할 수 있다. 
    // Authorization Code를 서버로 보내주고 서버에서 Access Token 요청을 하는 것이 적절함. 
    try {
      let result = await axios.post("https://localhost:4000/callback", {
        authorizationCode: authorizationCode,
      });
      setAccessToken(result.data.accessToken);
      setIsLogin(true);
    } catch {
      console.log("error");
    }

(위 코드에서는 try와 catch를 이용해 코드를 완성시켜주었는데, async await가 쓰인 경우에는 then이나 catch를 사용할 수 없으므로 try, catch를 이용해준다.)

사실 이 함수만 보면 클라이언트에서 바로 액세스 토큰을 받아오는 것처럼 보이지만, 사실은 callback 파일을 서버 파일 속에 있다.

//Server/controllers/users/callback.js

require("dotenv").config();
const axios = require("axios");
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET; 

module.exports = async (req, res) => {
  // req의 body로 authorization code가 들어옴. 
  //**바로 위 코드 스니펫에서 axios로 post 요청을 보낼 때, 보내줄 데이터로 auth code를 넣어주었기 때문에! **
  //아래와 같은 형식으로 code 속에 든 auth code와 액세스 토큰을 교환해준다. 
  try {
    const result = await axios({
      method: "post",
      url: `https://github.com/login/oauth/access_token`,
      headers: {
        accept: "application/json",
      },
      data: {
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        code: req.body.authorizationCode,
      },
    });
    const accessToken = result.data.access_token; 
    
    return res.status(200).send({ accessToken });
  } catch (err) {
    return res.status(401).send({ message: "error" });
  }
};


클라이언트 App.js 파일에서 Mypage 컴포넌트에 setIsLogin 상태 변경 함수와 accessToken을 props로 전달해주고, 아래와 같이 Mypage에서도 불러온다.

//Client/Mypage.js 

export default function Mypage({ accessToken, setIsLogin, setAccessToken }) {
  const [githubUser, setGithubUser] = useState(null);
  const [serverResource, setServerResource] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  const logoutHandler = () => {
  };

  useEffect(() => {
  }, []);

  return (생략)
}



3. 발급 받은 Access Token을 이용해 사용자 정보를 받아온다 : userInfo 엔드 포인트로 요청을 보낸다

클라이언트가 아래와 같이 userInfo라는 엔드포인트로 사용자 정보에 대한 요청을 보내면

//Client/Mypage.js 

 useEffect(() => {
    axios
      .post("https://localhost:4000/userInfo", { accessToken })
      .then((res) => {
        const { githubUserData, serverResource } = res.data;
        setGithubUser(githubUserData);
        setServerResource(serverResource);
        setIsLoading(false);
      })
      .catch((err) => console.log(err));
  }, []);

서버의 userInfo에서 클라이언트에서 전달받은 access token를 이용해 사용자의 정보를 가져온다. 이때 로컬 서버가 요청하는 깃허브의 서버는 Resource Server이다. (Auth server가 아니라)

Users API 문서

//Server/userInfo.js 파일

module.exports = async (req, res) => {
  const { accessToken } = req.body;
  return axios
    .get("https://api.github.com/user", {
      headers: {
        Authorization: `token ${accessToken}`,
      },
    })
    .then((res) => res.data)
    .then((githubUserData) => {
      res.send({ githubUserData, serverResource });
    })
    .catch((e) => {
      res.sendStatus(403);
    });
};

서버에서 응답으로 보내는 githubUserData와 serverResource를 클라이언트에서 받아서 Mypage 파일 속 상태인 githubUser, serverResource를 변경시켜줄 수 있다.

로그인이 완료되고, 사용자 정보가 불러와지면 이렇게 내 정보 화면까지 뜨는 것을 확인 할 수 있다.




4. Mypage 컴포넌트 내의 로그아웃 버튼을 누르면 => logout 엔드 포인트로 엑세스 토큰을 보낸다 => 유저의 토큰을 지우고, 관련된 상태를 변경한다.

우선 이미 작성되어 있는 서버 파일을 확인해본다. 클라이언트에서 logout 엔드 포인트로 요청을 보내면, 바디에 담긴 액세스 토큰을 accessToken 변수에 할당한다.

delete를 이용해서 액세스 토큰을 지우고, 'Successfuly Logged Out' 문구를 응답으로 보낸다.

delete an OAuth App 참고

//Server/logout.js

require('dotenv').config();
const axios = require('axios');
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;

module.exports = (req, res) => {
  const { accessToken } = req.body;
  axios
    .delete(`https://api.github.com/applications/${CLIENT_ID}/token`, {
      data: {
        access_token: accessToken,
      },
      auth: {
        username: CLIENT_ID,
        password: CLIENT_SECRET,
      },
    })
    .then(() => {
      res.status(205).send('Successfuly Logged Out');
    })
    .catch((e) => {
      console.log(e.response);
    });
};

클라이언트에서 delete 매서드를 사용할 때에도 delete 매서드는 body를 받지 못하기 때문에 data라는 키 값으로 한 번 더 감싸서 요청을 보내야 한다.

그리고 요청을 통해 성공적인 응답을 받게 되면, 로그인 상태를 false로 바꾸어주고, access Token의 상태도 빈 문자열로 바꿔준다.
그리고 나머지 상태들도 기본값인 null로 바꾸어주면 된다.

const logoutHandler = () => {
    // TODO: /logout을 통해 사용자가 로그아웃되도록 구현하세요.
    // prop으로 받은 Access Token을 이용해 /logout 엔드포인트로 요청을 보내야합니다.
    // 요청이 성공했다면 isLogin 상태를 false로 업데이트해야 합니다.
    //delete method는 사용방법이 좀 다름 - 딜리트는 바디를 못 받아줌
    //data라는 키 값으로 한 번 더 감싸서 보내줘야 한다
    axios
      .delete("https://localhost:4000/logout", { data: { accessToken } })
      .then((res) => {
        setIsLogin(false);
        setAccessToken("");
        setGithubUser(null);
        setServerResource(null);
      });
  };




🍂 인증/보안 파트를 마치며...

  • 네트워크 단원을 공부할 때도 그랬지만 백엔드가 가미되면 클라이언트의 입장이 아니라 서버의 입장이 되어야 한다는 점이 헷갈리는 것 같다.

  • 깃허브 말고도 카카오톡이나 네이버 소셜 로그인이 어딜 가도 보이는데, 주말에 카카오톡이나 네이버 API를 참고해서 한 번 구현해봐도 좋을 것 같다는 생각이 들었다.

profile
예술적인 코드를 짜는 프론트 엔드 개발자가 꿈입니다! (나는야 코드 아티스트! 🤭)

0개의 댓글