본문 바로가기
개발/React

리액트 - 쿠키로 GitHub OAuth 로그인 인증 관리하기 with react-cookie

by 피로물든딸기 2023. 11. 15.
반응형

리액트 전체 링크

Git / GitHub 전체 링크

 

참고

- 로컬 스토리지 사용 방법과 세션 스토리지 비교

 

댓글 기능 만들기 with react-comments-section
로그인한 사용자만 댓글 기능 사용하기
GitHub RESTful API로 댓글 저장하기
리액트 쿠키로 GitHub OAuth 로그인 인증 관리하기

- Mui Pagination으로 댓글 페이지로 나누기

 

현재 로그인 인증은 로컬 스토리지를 이용하여 처리하였다.

react-cookie를 이용하여 로그인 인증 정보를 관리해보자.

 

로컬 스토리지와 쿠키의 차이는 다음과 같다.

  로컬 스토리지 쿠키
용량 최소 5MB 이상 최대 4KB
전송 서버에 자동 전송 X (직접 구현) HTTP 요청 헤더에 자동 포함
수명 만료 기간 미설정시 영구 유지 만료 날짜 또는 세션 동안 유지
접근성 JavaScript로 읽고 쓰기 JavaScript에서 읽을 수 없도록 설정 가능
보안 모든 js 코드에서 읽고 쓰기 때문에
민감한 정보는 저장 x
HttpOnly로 읽을 수 없도록 설정 가능
Secure로 HTTPS 연결에서만 전송하도록 보호

 

쿠키가 세션 관리나 사용자 인증, 트래킹 등과 같은 작은 데이터를 저장할 때 조금 더 유용할 수 있다.

 

설치

npm install react-cookies --legacy-peer-deps

react-cookie 사용 예시

 

쿠키를 get/set/remove하는 예시는 다음과 같다.

import React from "react";
import { Cookies } from "react-cookie";

const cookies = new Cookies();

const ReactCookie = () => {
  const setCookie = () => {
    cookies.set("cookie_test", "testValue", {
      path: "/",
      maxAge: 3, // seconds
      secure: true, // https 연결에만 전송
      // httpOnly: true, // 클라이언트에서 read 불가
    });
  };

  const getCookie = () => {
    console.log(cookies.get("cookie_test"));
  };

  const removeCookie = () => {
    cookies.remove("cookie_test");
  };

  return (
    <div>
      <button onClick={setCookie}>
        쿠키 설정
      </button>
      <button onClick={getCookie}>
        쿠키 확인
      </button>
      <button onClick={removeCookie}>
        쿠키 제거
      </button>
    </div>
  );
};

export default ReactCookie;

 

설정된 쿠키는 개발자 모드에서 Application 탭에서 확인 가능하다.

 

위의 코드에서 설정한대로 3초 동안 쿠키가 유지되는 것을 알 수 있다.


useCookies

 

react-cookie는 useCookies 훅을 제공한다. 예시는 다음과 같다.

import React, { useState, useEffect } from "react";
import { useCookies } from "react-cookie";

const ReactCookie = () => {
  const [cookies, setCookie, removeCookie] = useCookies(["githubToken"]);
  const [token, setToken] = useState(cookies.githubToken || null);

  useEffect(() => {
    const storedToken = cookies.githubToken;
    if (storedToken) {
      setToken(storedToken);
    }
  }, [cookies.githubToken]);

  const handleLogin = () => {
    // github oauth callback으로 token 획득
    const receivedToken = "received_token_from_oauth";
    
    setCookie("githubToken", receivedToken, {
      maxAge: 3, // seconds
      secure: true, // https 연결에만 전송
      // httpOnly: true, // 클라이언트에서 read 불가
    });

    setToken(receivedToken);
  };

  const handleCheck = () => {
    console.log(cookies);
  };

  const handleLogout = () => {
    removeCookie("githubToken");
    setToken(null);
  };

  return (
    <div>
      {token ? <p>login</p> : <p>logout</p>}
      <button onClick={handleLogin}>로그인</button>
      <button onClick={handleCheck}>쿠키 확인</button>
      <button onClick={handleLogout}>로그아웃</button>
    </div>
  );
};

export default ReactCookie;

 

마찬가지로 Application 탭에서 확인 가능하다.

 

useCookies도 정상적으로 쿠키를 설정하는지 확인해보자.


깃허브 OAuth 콜백 함수에 쿠키 적용하기

 

깃허브 코드를 아래와 같이 고치자.

 

여기서는 cookielibrary.js를 만들어서 get/set/remove를 할 수 있도록 하였다.

import { Cookies } from "react-cookie";

const cookies = new Cookies();

export const setCookies = (key, value, options) => {
  return cookies.set(key, value, {...options})
}

export const getCookies = (key) => {
  return cookies.get(key)
}

export const removeCookies = (key) => {
  return cookies.remove(key);
}

 

githublibrary.js에서 로컬 스토리지 코드를 쿠키로 수정한다.

import * as ck from "./cookielibrary.js";

...

export const loginCheck = async (setLoginStatus) => {
  let token = ck.getCookies("GITHUB_TOKEN");
  ...
};

export const getLoginStatus = async () => {
  let token = ck.getCookies("GITHUB_TOKEN");
  ...
};

 

GitHubLoginCallback.js도 수정한다. 60초 동안 로그인이 유지되도록 옵션을 설정하였다.

...

import * as ck from "../cookielibrary.js";

...

const GitHubLoginCallback = ({ loginStatus, setLoginStatus }) => {
  const navigate = useNavigate();

  const getAccessToken = async (code) => {
    try {
      let server = `http://192.168.55.120:3002`;
      let accessInfo = await axios.get(
        `${server}/githubLogin?code=${code}&clientID=${clientID}`
      );

      let token = accessInfo.data.token;

      const userResponse = await axios.get("https://api.github.com/user", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const userData = userResponse.data;
      const avatarUrl = userData.avatar_url;
      const loginID = userData.login;

      ...
      
      let options = {
        path: "/",
        maxAge: 60, // seconds
        secure: true, // https 연결에만 전송
        // httpOnly: true, // 클라이언트에서 read 불가
      };

      ck.setCookies("GITHUB_TOKEN", token, options);
      ck.setCookies("AVATAR_URL", avatarUrl, options);
      ck.setCookies("LOGIN_ID", loginID, options);
      
      setLoginStatus(true);

      navigate('/',  { replace: true });
    } catch (e) {
      console.log(e);
      setLoginStatus(false);
    }
  };
  ...
};

 

App.js에서 로그인 확인 코드를 쿠키로 변경한다.

...
import * as ck from "./cookielibrary.js";

const App = () => {
  const [loginStatus, setLoginStatus] = useState(false);
  const [currentUser, setCurrentUser] = useState(null);

  useEffect(() => {
    if(loginStatus === false) return;
    let loginID = ck.getCookies("LOGIN_ID");
    let url = ck.getCookies("AVATAR_URL");
    let profile = `https://github.com/${loginID}`;
    
    ...

 

마지막으로 ReactComents.js의 getCurrentUser에도 쿠키를 처리한다.

참고로 쿠키가 없는 경우는 null이 아니라 undefined를 return 받는다.

  const getCurrentUser = () => {
    if (currentUser === null) {
      let loginID = ck.getCookies("LOGIN_ID");
      let url = ck.getCookies("AVATAR_URL");
      let profile = `https://github.com/${loginID}`;

      if (loginID === null || loginID === undefined) return null;

      return {
        currentUserId: loginID,
        currentUserImg: url,
        currentUserProfile: profile,
        currentUserFullName: loginID,
      };
    }

    return currentUser;
  };

 

60초 동안 로그인이 유지되는지 직접 확인해보자.


전체 코드는 다음과 같다.

 

cookielibrary.js

import { Cookies } from "react-cookie";

const cookies = new Cookies();

export const setCookies = (key, value, options) => {
  return cookies.set(key, value, {...options})
}

export const getCookies = (key) => {
  return cookies.get(key)
}

export const removeCookies = (key) => {
  return cookies.remove(key);
}

 

githublibrary.js

import axios from "axios";
import { Octokit } from "@octokit/rest";

import * as ck from "./cookielibrary.js";

const myKey = process.env.REACT_APP_MY_TOKEN;
const repo = `auto-test`; 

export const fileRead = async (path) => {
  try {
    const octokit = new Octokit({
      auth: myKey,
    });

    const result = await octokit.request(
      `GET /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        encoding: "utf-8",
        decoding: "utf-8",
      }
    );
    return result;
  } catch (e) {
    console.log("error : ", e);
    return undefined;
  }
};

export const fileCreate = async (contents, path) => {
  const octokit = new Octokit({
    auth: myKey,
  });

  try {
    const result = await octokit.request(
      `PUT /repos/bloodstrawberry/${repo}/contents/${path}`,
      {
        owner: "bloodstrawberry",
        repo: `${repo}`,
        path: `${path}`,
        message: "make comments",
        committer: {
          name: "bloodstrawberry",
          email: "bloodstrawberry@github.com",
        },
        content: `${btoa(unescape(encodeURIComponent(`${contents}`)))}`,
        headers: {
          "X-GitHub-Api-Version": "2022-11-28",
        },
      }
    );
    
    return result.status; 
  }
  catch (e) {
    console.log("error : ", e);
    return undefined;
  }
};

export const loginCheck = async (setLoginStatus) => {
  let token = ck.getCookies("GITHUB_TOKEN");
  try {
    const response = await axios.get("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    console.log(response);
    setLoginStatus(true);
  } catch (error) {
    console.error("Error fetching user data:", error);
    setLoginStatus(false);
  }
};

export const getLoginStatus = async () => {
  let token = ck.getCookies("GITHUB_TOKEN");
  try {
    const response = await axios.get("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    console.log(response);
    return true;
  } catch (error) {
    console.error("Error fetching user data:", error);
    return false;
  }
};

 

GitHubLoginCallback.js

import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";

import axios from "axios";
import queryString from "query-string";

import * as ck from "../cookielibrary.js";

const clientID = process.env.REACT_APP_CLIENT_ID;

const GitHubLoginCallback = ({ loginStatus, setLoginStatus }) => {
  const navigate = useNavigate();

  const getAccessToken = async (code) => {
    try {
      let server = `http://192.168.55.120:3002`;
      let accessInfo = await axios.get(
        `${server}/githubLogin?code=${code}&clientID=${clientID}`
      );

      let token = accessInfo.data.token;

      const userResponse = await axios.get("https://api.github.com/user", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const userData = userResponse.data;
      const avatarUrl = userData.avatar_url;
      const loginID = userData.login;

      console.log(userData);
      console.log(avatarUrl);
      console.log(loginID);

      let options = {
        path: "/",
        maxAge: 60, // seconds
        secure: true, // https 연결에만 전송
        // httpOnly: true, // 클라이언트에서 read 불가
      };

      ck.setCookies("GITHUB_TOKEN", token, options);
      ck.setCookies("AVATAR_URL", avatarUrl, options);
      ck.setCookies("LOGIN_ID", loginID, options);
      
      setLoginStatus(true);

      navigate('/',  { replace: true });
    } catch (e) {
      console.log(e);
      setLoginStatus(false);
    }
  };

  const getCode = () => {
    let qs = queryString.parse(window.location.search);
    let code = qs.code;
    getAccessToken(code);
  };

  useEffect(() => getCode(), []);

  return <div>{loginStatus ? "로그인 성공!" : "로그인 실패..."}</div>;
};

export default GitHubLoginCallback;

 

App.js

import React, { useEffect, useState } from "react";
import { Route, Link, Routes } from "react-router-dom";

import "./App.css";

// ...
import ReactComments from "./page/ReactComments";
import GitHubLoginCallBack from "./page/GitHubLoginCallback";

import * as gh from "./githublibrary.js";
import * as ck from "./cookielibrary.js";

const App = () => {
  const [loginStatus, setLoginStatus] = useState(false);
  const [currentUser, setCurrentUser] = useState(null);

  useEffect(() => {
    if(loginStatus === false) return;
    let loginID = ck.getCookies("LOGIN_ID");
    let url = ck.getCookies("AVATAR_URL");
    let profile = `https://github.com/${loginID}`;

    setCurrentUser({
      currentUserId: loginID,
      currentUserImg: url,
      currentUserProfile: profile,
      currentUserFullName: loginID,
    });
  }, [loginStatus]);

  useEffect(() => {
    gh.loginCheck(setLoginStatus);
  }, []);

  return (
    <div className="App">
      <div className="router">
        <span>
          <Link to="/comments">Comments</Link>
        </span>
      </div>
      <div>
        <Routes>
          <Route
            path="/comments"
            element={<ReactComments currentUser={currentUser} />}
          />
          <Route
            path="/callback"
            element={
              <GitHubLoginCallBack
                loginStatus={loginStatus}
                setLoginStatus={setLoginStatus}
              />
            }
          />
        </Routes>
      </div>
    </div>
  );
};

export default App;

 

ReactComments.js

import React, { useEffect, useState } from "react";
import { CommentSection } from "react-comments-section";
//import 'react-comments-section/dist/index.css';
import "../css/comment.css";
import * as gh from "../githublibrary.js";
import * as ck from "../cookielibrary.js";

const clientID = process.env.REACT_APP_CLIENT_ID;
const callbackURL = "http://localhost:3000/callback";
const loginURL = `https://github.com/login/oauth/authorize?client_id=${clientID}&scope=repo:status read:repo_hook user:email&redirect_uri=${callbackURL}`;

const getCurrentDateTime = () => {
  const now = new Date();

  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, "0");
  const day = String(now.getDate()).padStart(2, "0");
  const hours = String(now.getHours()).padStart(2, "0");
  const minutes = String(now.getMinutes()).padStart(2, "0");
  const seconds = String(now.getSeconds()).padStart(2, "0");

  const formattedDateTime = `${year}_${month}_${day}_${hours}_${minutes}_${seconds}`;
  return formattedDateTime;
};

const ReactComments = ({ currentUser }) => {
  const [initComments, setInitComments] = useState([]);

  const currentData = (data) => {
    console.log("current data", data);
  };

  const getCurrentUser = () => {
    if (currentUser === null) {
      let loginID = ck.getCookies("LOGIN_ID");
      let url = ck.getCookies("AVATAR_URL");
      let profile = `https://github.com/${loginID}`;

      if (loginID === null || loginID === undefined) return null;

      return {
        currentUserId: loginID,
        currentUserImg: url,
        currentUserProfile: profile,
        currentUserFullName: loginID,
      };
    }

    return currentUser;
  };

  const loadComments = async () => {
    let commentDir = `comments`;
    let fileList = await gh.fileRead(commentDir);

    if (fileList === undefined) return; // 아무 댓글도 없는 경우

    // original_comments.js 찾기
    let originalComment = fileList.data.filter(
      (file) => file.name === "original_comments.js"
    );
    let originalCommentData = [];
    if (originalComment.length) {
      let result = await gh.fileRead(originalComment[0].path);
      if (result === undefined) return;

      originalCommentData = JSON.parse(
        decodeURIComponent(escape(window.atob(result.data.content)))
      );
    }

    let commentsAction = fileList.data.filter(
      (file) => file.name !== "original_comments.js"
    );

    for (let action of commentsAction) {
      let result = await gh.fileRead(action.path);

      if (result === undefined) {
        console.error("error data");
        continue;
      }

      //console.log(result.data.name);
      let contents = JSON.parse(
        decodeURIComponent(escape(window.atob(result.data.content)))
      );

      //console.log(contents);
      if (contents.type === "submit") {
        delete contents.type;
        originalCommentData.push(contents);
      } else if (contents.type === "delete") {
        let comIdToDelete = contents.comIdToDelete;
        let parentOfDeleteId = contents.parentOfDeleteId;

        if (parentOfDeleteId === undefined) {
          // 댓글 삭제
          originalCommentData = originalCommentData.filter(
            (data) => data.comId !== comIdToDelete
          );
        } else {
          // reply 삭제
          let parent = originalCommentData.filter(
            (data) => data.comId === parentOfDeleteId
          )[0];
          parent.replies = parent.replies.filter(
            (data) => data.comId !== comIdToDelete
          );
        }
      } else if (contents.type === "reply") {
        let repliedToCommentId =
          contents.parentOfRepliedCommentId || contents.repliedToCommentId;
        let parent = originalCommentData.filter(
          (data) => data.comId === repliedToCommentId
        )[0];
        delete contents.type;
        parent.replies.push(contents);
      } else if (contents.type === "edit") {
        let comId = contents.comId;
        let parentOfEditedCommentId = contents.parentOfEditedCommentId;

        if (parentOfEditedCommentId === undefined) {
          // 댓글을 변경한 경우
          let parentText = originalCommentData.filter(
            (data) => data.comId === comId
          )[0];
          parentText.text = contents.text;
        } else {
          // reply를 변경한 경우
          let parent = originalCommentData.filter(
            (data) => data.comId === parentOfEditedCommentId
          )[0];
          let child = parent.replies.filter((data) => data.comId === comId)[0];
          child.text = contents.text;
        }
      }
    }

    setInitComments(originalCommentData);

    if (commentsAction.length === 0) return;

    // 기존 data는 삭제
    let originalPath = `comments/original_comments.js`;
    let result = await gh.fileDelete(originalPath);

    console.log(`${originalPath} delete : ${result}`);

    // 새로 생성
    result = await gh.fileCreate(
      JSON.stringify(originalCommentData, null, 2),
      originalPath
    );

    console.log(result);

    // 불필요한 action 삭제
    for (let action of commentsAction) {
      result = await gh.fileDelete(action.path);
      console.log(`${action.path} delete : ${result}`);
    }
  };

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

  const saveCommentAction = async (data) => {
    console.log("save Comments Action");

    let path = `comments/${getCurrentDateTime()}_data.json`;
    let result = await gh.fileCreate(JSON.stringify(data, null, 2), path);

    console.log(result);
  };

  const onSubmitAction = (data) => {
    console.log("check submit, ", data);

    data.type = "submit";
    saveCommentAction(data);
  };

  const onDeleteAction = (data) => {
    console.log("check Delete, ", data);
    data.type = "delete";
    saveCommentAction(data);
  };

  const onReplyAction = (data) => {
    console.log("check Reply, ", data);
    data.type = "reply";

    saveCommentAction(data);
  };

  const onEditAction = (data) => {
    console.log("check Edit, ", data);

    data.type = "edit";
    saveCommentAction(data);
  };

  return (
    <div>
      <CommentSection
        currentUser={getCurrentUser}
        logIn={{
          loginLink: loginURL,
          signupLink: "https://github.com/",
        }}
        commentData={initComments}
        currentData={currentData}
        onSubmitAction={onSubmitAction}
        onDeleteAction={onDeleteAction}
        onReplyAction={onReplyAction}
        onEditAction={onEditAction}
        hrStyle={{ border: "0.5px solid #ff0072" }}
        inputStyle={{ border: "1px solid rgb(208 208 208)" }}
        formStyle={{ backgroundColor: "white" }}
        submitBtnStyle={{
          border: "1px solid black",
          backgroundColor: "black",
          padding: "7px 15px",
        }}
        cancelBtnStyle={{
          border: "1px solid gray",
          backgroundColor: "gray",
          color: "white",
          padding: "7px 15px",
        }}
        replyInputStyle={{ borderBottom: "1px solid black", color: "black" }}
        advancedInput={true}
        removeEmoji={false}
      />
    </div>
  );
};

export default ReactComments;
반응형

댓글