GitHub으로 사용자 정보 가져오기(OAuth2.0), JWT 계정인증 구현(accessToken, refreshToken)

박재하·2023년 11월 24일
0

목표

  • OAuth 2.0으로 GitHub에서 로그인 정보 가져오기
    • Github 로그인 버튼 만들기
    • Github에 OAuth application 등록
    • 로그인 요청 및 응답받기
  • 프로젝트에서 Github 사용자 데이터 가져오기
    • reqGitHubLogin (사용자에게 깃헙 인증 요청, 코드 받아오기)
    • getAccessTokenFromCode (코드 전달, 액세스 토큰 받아오기)
    • getGitHubUserData (액세스 토큰 전달, 사용자 데이터 받아오기)
  • JWT 계정인증 구현
    • 모듈 설치 및 토큰 반환
    • 로그인 검증 로직 구현
    • refresh 로직 구현
  • 프로젝트에서 Github 로그인 기능 완성하기
    • generateToken (JWT 생성하여 반환)
    • verifyToken (JWT 검증)
    • handleTokenRefresh (refreshToken으로 accessToken 생성하여 반환)
    • 테스트

고민과 해결 과정

OAuth 2.0으로 GitHub에서 로그인 정보 가져오기

Github 로그인 버튼 만들기

학습메모 1을 참고해 로그인 버튼에 필요한 깃헙 로고 SVG 파일을 받아왔다.

HTML/CSS를 편집해 예쁜 Github으로 로그인하기 버튼을 만들어줬다!

스크린샷 2023-10-23 오후 12 51 14

Github에 OAuth application 등록

학습메모 2를 참고해 OAuth application을 등록했다.

스크린샷 2023-10-23 오후 1 07 39

client_id와 client_secret 값을 얻을 수 있다!

로그인 요청 및 응답받기

이제 학습메모 3을 참고해 어떤 url로 Github으로 로그인을 요청해야 하는지를 확인해보자.

import * as queryString from "query-string";

const params = queryString.stringify({
  client_id: process.env.APP_ID_GOES_HERE,
  redirect_uri: "https://www.example.com/authenticate/github",
  scope: ["read:user", "user:email"].join(" "), // space seperated string
  allow_signup: true,
});

const githubLoginUrl = `https://github.com/login/oauth/authorize?${params}`;
https://github.com/login/oauth/authorize?client_id=[...]&redirect_uri=http://localhost:3000/login/github&scope=read:user%20user:email&allow_signup=true

위의 client_id에 방금 등록해서 얻은 client_id를 넣어주면 완성.

스크린샷 2023-10-23 오후 1 21 40

이 URL로 브라우저에서 요청을 보내보자. 우리가 원하는 Github Authorize
페이지를 볼 수 있다. 성공!

스크린샷 2023-10-23 오후 1 34 37

물론 callback url에 대한 처리를 안해줬으므로 Authorize qkrwogk
버튼을 눌러도 404 Not Found가 나온다. request요청을 로그로 남겨 확인 먼저 해봤다.

스크린샷 2023-10-23 오후 1 31 57
http://localhost:3000/login/github?code=1315cae6eea5e0bc9303

위와 같은 code 값이 url 파라미터로 넘어온다.

학습메모 3을 계속 참고해보면, 이 code를 이용해서 다시 요청을 보내야 한다.

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

async function getAccessTokenFromCode(code) {
  const { data } = await axios({
    url: "https://github.com/login/oauth/access_token",
    method: "get",
    params: {
      client_id: process.env.APP_ID_GOES_HERE,
      client_secret: process.env.APP_SECRET_GOES_HERE,
      redirect_uri: "https://www.example.com/authenticate/github",
      code,
    },
  });
  /**
   * GitHub returns data as a string we must parse.
   */
  const parsedData = queryString.parse(data);
  console.log(parsedData); // { token_type, access_token, error, error_description }
  if (parsedData.error) throw new Error(parsedData.error_description);
  return parsedData.access_token;
}
https://github.com/login/oauth/access_token?client_id=[...]&client_secret=[...]&redirect_uri=http://localhost:3000/login/github&code=[...]

앞서 획득한 code, 그리고 앱 등록시 획득한 client_id, client_secret을
넣어서 테스트해보자!

스크린샷 2023-10-23 오후 2 19 02 스크린샷 2023-10-23 오후 2 16 58

위와 같이 access_token이라는 파일이 전달된다.

error=bad_verification_code
&error_description=The+code+passed+is+incorrect+or+expired.
&error_uri=https%3A%2F%2Fdocs.github.com%2Fapps%2Fmanaging-oauth-apps%2Ftroubleshooting-oauth-app-access-token-request-errors%2F%23bad-verification-code

에러 발생 시(code가 잘못되면) 위와 같은 에러가 뜨고

access_token=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
&scope=read%3Auser%2Cuser%3Aemail
&token_type=bearer

성공하면 위와 같이 access_token이 반환된다.

이제 학습메모3을 참고하여 마지막으로 access_token을

import axios from "axios";

async function getGitHubUserData(access_token) {
  const { data } = await axios({
    url: "https://api.github.com/user",
    method: "get",
    headers: {
      Authorization: `token ${access_token}`,
    },
  });
  console.log(data); // { id, email, name, login, avatar_url }
  return data;
}

Header를 넣어야 하므로 이번엔 Postman 앱에서 요청을 보내보겠다.

스크린샷 2023-10-23 오후 3 15 17

잘 나온다 나온다!!! 여기서 id, email, name, login, avatar_url
필요한 정보를 추출해서 사용하면 된다. 전체 데이터 구조를 살펴보자.

{
    "login": "qkrwogk",
    "id": ...,
    "node_id": "...",
    "avatar_url": "https://avatars.githubusercontent.com/u/138586629?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/qkrwogk",
    "html_url": "https://github.com/qkrwogk",
    "followers_url": "https://api.github.com/users/qkrwogk/followers",
    "following_url": "https://api.github.com/users/qkrwogk/following{/other_user}",
    "gists_url": "https://api.github.com/users/qkrwogk/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/qkrwogk/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/qkrwogk/subscriptions",
    "organizations_url": "https://api.github.com/users/qkrwogk/orgs",
    "repos_url": "https://api.github.com/users/qkrwogk/repos",
    "events_url": "https://api.github.com/users/qkrwogk/events{/privacy}",
    "received_events_url": "https://api.github.com/users/qkrwogk/received_events",
    "type": "User",
    "site_admin": false,
    "name": "박재하",
    "company": null,
    "blog": "",
    "location": null,
    "email": null,
    "hireable": null,
    "bio": null,
    "twitter_username": null,
    "public_repos": 7,
    "public_gists": 0,
    "followers": 38,
    "following": 44,
    "created_at": "2023-07-04T11:41:36Z",
    "updated_at": "2023-10-07T02:27:29Z",
    "private_gists": 15,
    "total_private_repos": 5,
    "owned_private_repos": 3,
    "disk_usage": 8997,
    "collaborators": 0,
    "two_factor_authentication": ...,
    "plan": {
        "name": "pro",
        "space": 976562499,
        "collaborators": 0,
        "private_repos": 9999
    }
}

우선 id, name, email 정도만 받아와서 로그인되게 해보자!

프로젝트에서 Github 사용자 데이터 가져오기

reqGitHubLogin (사용자에게 깃헙 인증 요청, 코드 받아오기)

FE에서 만들어둔 github 로그인 버튼 클릭 시 킬릭 이벤트로 reqGitHubLogin 함수를 등록해줬다.

해당 함수는 학습메모 5를 참고하여 URL 파라미터에 앞서 테스트해본 BE로 코드 받아오기 기능을 구현한다.

// fetch.js

const reqGitHubLogin = async () => {
  const url = new URL("https://github.com/login/oauth/authorize");
  const searchParams = new URLSearchParams();
  searchParams.append("client_id", "47cc85d150a21a06a19a");
  searchParams.append("redirect_uri", "http://localhost:3000/login/github");
  searchParams.append("scope", "read:user user:email");
  searchParams.append("allow_signup", "true");
  url.search = searchParams.toString();

  window.open(url.toString());

  return;
};

export { ..., reqGitHubLogin };
// user/login.js
const setEvtsOnLogin = () => {
  document
    .querySelector("button.github")
    .addEventListener("click", reqGitHubLogin);
};

const onWindowLoad = () => {
  setActsOnLogin();
  setEvtsOnLogin();
};

완성된 기능은 login.js에서 버튼에 콜백함수로 등록해준다.

getAccessTokenFromCode (코드 전달, 액세스 토큰 받아오기)

스크린샷 2023-10-23 오후 4 07 32

이제 GitHub 로그인 버튼을 클릭하면 코드가 파라미터로 담겨 잘 리다이렉트되는데,
서버에서 code 값을 읽어와 다시 액세스 토큰을 받아오는 로직을 구현하지 않아 404인 상태.

구현해주자.

// loginRouter.js
...
loginRouter.get("/login/github", handleGitHubLogin );
...

우선 로그인 라우터에 GET /login/github을 등록해주고

// loginController.js

const handleGitHubLogin = async (req, res) => {
  try {
    const { code } = req.queryObj;
    console.log("[OAuth] code: ", code);

    const accessToken = await getAccessTokenFromCode(code);

    if (!accessToken) throw new Error("access_token을 받지 못함");
    console.log("[OAuth] accessToken: ", accessToken);

    const userData = await getGitHubUserData(accessToken);

    if (!userData) throw new Error("userData를 받지 못함");

    res.json({ email: userData.login, nickname: userData.name });
    return;
  } catch (e) {
    console.log(e);
    res.error(500, e.toString());
  }
};

export { isLoggedIn, doLogin, handleGitHubLogin };

이런 느낌으로 구현해줬다. getAccessTokenFromCode()는 다음과 같다.

const getAccessTokenFromCode = async (code) => {
  const url = new URL("https://github.com/login/oauth/access_token");
  const searchParams = new URLSearchParams();
  searchParams.append("client_id", github_oauth_config.client_id);
  searchParams.append("client_secret", github_oauth_config.client_secret);
  searchParams.append("redirect_uri", "http://localhost:3000/login/github");
  searchParams.append("code", code);
  url.search = searchParams.toString();

  const res = await fetch(url.toString());
  const resParams = new URLSearchParams(await res.text());
  const accessToken = resParams.get("access_token");

  return accessToken;
};

code를 전달하고 accessToken을 받아온다.

getGitHubUserData (액세스 토큰 전달, 사용자 데이터 받아오기)

const getGitHubUserData = async (accessToken) => {
  const url = new URL("https://api.github.com/user");

  const res = await fetch(url.toString(), {
    headers: {
      Authorization: `token ${accessToken}`,
    },
  });
  const userData = await res.json();

  return userData;
};

accessToken을 전달하고 userData를 받아온다. 완성!

테스트

이제 GitHub로그인 버튼을 클릭해보자.

스크린샷 2023-10-23 오후 5 06 52

잘 받아온다. 이제 이 정보로 세션이 아닌 JWT로 계정 관리를 해보자!

JWT 계정인증 구현

모듈 설치 및 토큰 반환

npm i jsonwebtoken

JWT(Json Web Token) node.js 모듈을 설치해준다.

토큰을 만들고 반환하는 로직은 간단하다. (chatGPT 참고)

// 로그인 라우트
app.post("/login", (req, res) => {
  const { username, password } = req.body;

  // 사용자 인증 로직
  if (username === "exampleuser" && password === "password") {
    // Access Token 생성
    const accessToken = jwt.sign({ username }, secretKey, { expiresIn: "15m" });

    // Refresh Token 생성
    const refreshToken = jwt.sign({ username }, refreshSecretKey, {
      expiresIn: "7d",
    });

    res.json({ accessToken, refreshToken });
  } else {
    res.status(401).json({ message: "로그인 실패" });
  }
});

유효한 username, password가 오면, username값을 저장한 채 토큰 생성하는 것인데,
우리는 이미 검증된 OAuth를 이용했으므로 email과 nickname을 받아 넘겨주면 되시겠다.

또한 JWT 토큰을 브라우저에서 저장하는 방식은 3가지다. 학습메모 7 참고

  • Local Storage
  • Session Storage
  • Cookie

우리는 이미 어느정도 구현했고, 보안성도 가장 높다고 평가되는 쿠키에 저장해주기로 한다.

로그인 검증 로직 구현

다음으로는 로그인 검증 로직이다. (chatGPT 참고)

// 미들웨어 함수로 JWT 검증
function verifyToken(req, res, next) {
  const token = req.headers["authorization"];

  if (!token) {
    return res.status(403).json({ message: "토큰이 제공되지 않았습니다." });
  }

  jwt.verify(token, secretKey, (err, decoded) => {
    if (err) {
      return res.status(401).json({ message: "인증에 실패했습니다." });
    }

    // 사용자 정보를 요청 객체에 저장
    req.user = decoded;
    next();
  });
}

해당 로직은 express의 경우 middleware로 들어가야 하지만, 미들웨어 역할을 하도록
각 컨트롤러 앞단에 검사하는 조건문을 추가해주면 되겠다.

refresh 로직 구현

마찬가지로 chatGPT 참고.

// Refresh Token을 사용하여 Access Token 재발급
app.post("/refresh", (req, res) => {
  const refreshToken = req.body.refreshToken;

  jwt.verify(refreshToken, refreshSecretKey, (err, decoded) => {
    if (err) {
      return res
        .status(401)
        .json({ message: "Refresh Token이 유효하지 않습니다." });
    }

    // Refresh Token이 유효하면 새로운 Access Token 생성
    const accessToken = jwt.sign({ username: decoded.username }, secretKey, {
      expiresIn: "15m",
    });
    res.json({ accessToken });
  });
});

POST /login/refresh 요청이 들어오면 refreshToken을 재발급해주면 되시겠다.

프로젝트에서 Github 로그인 기능 완성하기

generateToken (JWT 생성하여 반환)

// loginController.js
const generateToken = (userData) => {
  const accessToken = jwt.sign(userData, jwt_config.secretKey, {
    expiresIn: "15m",
  });
  const refreshToken = jwt.sign(userData, jwt_config.refreshSecretKey, {
    expiresIn: "7d",
  });

  return { accessToken, refreshToken };
};

위에서 분석한 예시코드와 유사하게 코드를 구성해준다. secretKey들은 따로 파일로 관리해서
git repository에 push되지 않도록 해준다.

// loginController.js
const handleGitHubLogin = async (req, res) => {
  try {
    ...
    const tokens = generateToken({
      email: userData.login,
      nickname: userData.name,
    });

    res.cookie = tokens;
    ...
  } ...
};

res.cookie에 넣어줘서 Set-Cookie 등록을 해주는데, 2개의 쿠키를 등록해줘야 해서
기존의 HttpResponse 메소드로는 동작을 안하더라.

HTTP/1.1 200 OK ...
...
Set-Cookie: cookie1=value1; HttpOnly; Path=/
Set-Cookie: cookie2=value2; HttpOnly; Path=/
...

요약하자면 이런식으로 헤더를 두 번 넣어서 쿠키를 등록해줘야 한다. 이걸 구현해주자.

// HttpResponse.js
set cookie(cookieObj) {
  this.#cookieObj = cookieObj;
}
#pushSetCookieToHeaderList() {
  if (this.#cookieObj) {
    // 쿠키 등록이 필요하면
    Object.keys(this.#cookieObj).forEach((key) => {
      this.#headerList.push(
        `Set-Cookie: ${key}=${this.#cookieObj[key]}; HttpOnly; Path=/`
      );
    });
  }
}
...
redirect(url) {
  ...
  Object.keys(this.#headerObj).forEach((key) => {
    this.#headerList.push(`${key}: ${this.#headerObj[key]}`);
  });
  this.#pushSetCookieToHeaderList();
  ...
}

자 완성. 이제 JWT 토큰이 쿠키에 잘 들어가나 보자.

스크린샷 2023-10-23 오후 8 41 54

너무 잘되어버린다.

스크린샷 2023-10-23 오후 8 14 15

학습메모 8을 참고하여 등록된 JWT 토큰을 확인해보면 데이터가 잘 들어가 있는 것을 확인할 수 있다.


참고로 이전 url로 돌아가기 위해서는 redirect_uri callback 시 OAuth 표준에서도 제공하는
"state"라는 url parameter를 이용하면 되시겠다.

스크린샷 2023-10-23 오후 8 24 24

학습메모 2를 보면 이런 파라미터가 있다.

const reqGitHubLogin = async () => {
  const url = new URL("https://github.com/login/oauth/authorize");
  const searchParams = new URLSearchParams();
  ...

  // state는 원래 임의의 문자열을 생성해야 하나 여기서는 이전 페이지로 돌아가기 위해 사용
  const current_url = new URL(document.URL);
  const from = current_url.searchParams.get("from");
  searchParams.append("state", from ? from : "/");

  url.search = searchParams.toString();
  window.location.href = url.toString();
  return;
};

프론트 단에서 state 파라미터도 함께 전달해 GitHub 로그인을 요청해주고

const handleGitHubLogin = async (req, res) => {
  try {
    const { code, state } = req.queryObj;
    ...
    // 로그인 전 페이지로 이동 구현
    const url = state ? state : "/";
    console.log("[OAuth] redirect to: ", url);
    res.redirect(url);
    return;
  } ...
};

위와 같이 이전 페이지로의 이동을 구현할 수 있다.

verifyToken (JWT 검증)

이제 등록한 토큰을 검증해보자.

  1. index Page의 getLoginStatus(GET /login/status)에 대한 처리
  2. 기존의 GET /user/list, GET /boards/write, GET /boards/:id, POST /boards, POST /comments에 대한 로그인 검증 개선
1. GET /login/status에 대한 처리

GET /login/status에 대한 컨트롤러인 isLoggedIn 함수를 개선해보자.

const isLoggedIn = async (req, res) => {
  let loginStatus = req.session?.email ? true : false;
  let nickname = undefined;
  if (loginStatus) {
    try {
      const sessionDTO = new SessionDTO();
      sessionDTO.id = req.session?.id;
      sessionDTO.email = req.session?.email;
      const resultFindSession = await findSessionById(sessionDTO);
      if (resultFindSession.length === 0) {
        return new Error("email에 해당하는 세션을 찾지 못했습니다.");
      }

      const userDTO = new UserDTO();
      userDTO.email = req.session?.email;
      const resultFindUser = await findUserNicknameByEmail(userDTO);
      if (resultFindUser.length === 0) {
        return new Error("email에 해당하는 닉네임을 찾지 못했습니다.");
      }
      nickname = resultFindUser[0].nickname;

      res.json({ loginStatus, nickname: encodeURIComponent(nickname) });
      return;
    } catch (e) {
      res.json({ loginStatus: false, error: e.toString() });
      return;
    }
  } else if (req.cookie?.accessToken) {
    const accessToken = req.cookie.accessToken;

    try {
      const decoded = await jwt.verify(accessToken, jwt_config.secretKey);

      console.log("[LoginStatus] jwt decoded:", decoded);
      loginStatus = true;
      nickname = decoded.nickname;

      res.json({ loginStatus, nickname: encodeURIComponent(nickname) });
      return;
    } catch (e) {
      res.json({ loginStatus: false, error: e.toString() });
      return;
    }
  }
  res.json({ loginStatus: false, error: "no login" });
};

위쪽은 일반 로그인의 경우, 아래쪽은 일반 로그인이 수행되지 않은 경우 JWT 토큰을 확인하는 로직이다.
jwt.verify()결과 성공하면 nickname과 성공하였음을 반환하며, 실패하면 false와 에러 메시지를
반환한다.

2. GET /user/list, GET /boards/write, GET /boards/:id, POST /boards, POST /comments

우선 검증을 위한 verifyToken() 함수를 만들어준다.

// utils/verifyToken.js
import jwt from "jsonwebtoken";
import { jwt_config } from "../config/jwt_config.js";

const verifyToken = (accessToken) => {
  try {
    const decoded = jwt.verify(accessToken, jwt_config.secretKey);
    return { verified: true, decoded };
  } catch (e) {
    return { verified: false, decoded: undefined };
  }
};

export { verifyToken };

이제 기존의 로그인 검증 로직에 JWT 토큰 검증 로직을 조건문으로 추가해준다.

const { isLoggedIn } = await getLoginStatusAndEmail(req);
if (!isLoggedIn) {
  const from = req.path;
  res.redirect("/user/login.html" + `?from=${from}`);
  return;
}

이전에 이런 형태였다면

const { isLoggedIn } = await getLoginStatusAndEmail(req);
const accessToken = req.cookie?.accessToken;
const resultVerifyToken = verifyToken(accessToken);
if (!isLoggedIn && !resultVerifyToken.verified) {
  const from = req.path;
  res.redirect("/user/login.html" + `?from=${from}`);
  return;
}

이런 식으로 모두 바꿔줬다.

nickname을 가져오는 로직도 따로 만들어줬었기 때문에 Token방식에서랑 구분이 필요했는데,

// 닉네임 가져오기
let nickname = undefined;
if (isLoggedIn) {
  const userDTO = new UserDTO();
  userDTO.email = email;
  const resultFindUser = await findUserNicknameByEmail(userDTO);
  if (resultFindUser.length === 0) {
    return new Error("email에 해당하는 닉네임을 찾지 못했습니다.");
  }
  nickname = resultFindUser[0].nickname;
} else {
  nickname = resultVerifyToken.decoded.nickname;
}

이런식으로 (두 방식 모두로 로그인하는 경우는 없으므로) 구분해서 nickname에서 값을 받아줬다.

handleTokenRefresh (refreshToken으로 accessToken 생성하여 반환)

FE에서 loginStatus를 검사할 때(모든 페이지에서 검사함) accessToken이 유효하지 않으면
refreshToken으로 accessToken을 발급받는 로직을 만들어본다.

스크린샷 2023-10-23 오후 10 53 27

위 그림은 15분이 지나 accessToken이 만료될 때의 GET /login/status 결과 화면이다.

이 에러메시지를 활용하여 /refresh를 시도해보자.

const getLoginStatus = async () => {
  const url = "/login/status";
  const res = await fetch(url);
  const json = await res.json();
  const { loginStatus, nickname } = json;

  if (!loginStatus && json.error.includes("jwt expired")) {
    console.log("jwt token refresh 시도");

    // refresh 시도 후에도 로그인이 안 되어 있으면 false로 간주
    const url = "/login/refresh";
    const res = await fetch(url);
    const json = await res.json();

    return { loginStatus: json.loginStatus, nickname: json.nickname };
  }

  return { loginStatus, nickname: decodeURIComponent(nickname) };
};

재시도 로직 추가. 이제 이 GET /login/refresh 요청에 응답하며 쿠키에 accessToken을
갱신하는 컨트롤러 함수를 만들어 주자.

// loginController.js
const handleTokenRefresh = (req, res) => {
  const refreshToken = req.cookie?.refreshToken;
  if (!refreshToken) {
    res.error(401, "Refresh Token이 없음");
  }

  let decoded = undefined;
  try {
    decoded = jwt.verify(refreshToken, jwt_config.refreshSecretKey);
    console.log("[Token Refresh] decoded: ", decoded);
  } catch (e) {
    res.error(401, "Refresh Token이 유효하지 않음");
  }

  const userData = {
    email: decoded.email,
    nickname: decoded.nickname,
  };

  try {
    // Refresh Token이 유효하면 새로운 Access Token 생성
    const accessToken = jwt.sign(userData, jwt_config.secretKey, {
      expiresIn: "1m",
    });
    res.cookie = { accessToken };
    res.json({ loginStatus: true, nickname: decoded.nickname });

    console.log("[Token Refresh] new accessToken: ", accessToken);
    return;
  } catch (e) {
    res.error(500, e.toString());
  }
};

완성! Token Refresh 될 때 확인을 해보자!

스크린샷 2023-10-23 오후 11 27 24

시도했고

스크린샷 2023-10-23 오후 11 28 02

보냈고

스크린샷 2023-10-23 오후 11 28 30

받았고 완벽해 (동일한 값임을 확인)

스크린샷 2023-10-23 오후 11 30 18

set cookie도 확인.

테스트

스크린샷 2023-10-23 오후 11 19 42 스크린샷 2023-10-23 오후 11 20 24

글쓰기, 댓글쓰기, 글 조회, 유저리스트 조회 등 JWT 토큰으로 로그인한 유저도 모든 기능 잘 처리된다.

학습메모

  1. Github Logo
  2. Github: Authorizing OAuth apps
  3. Github OAuth with Node.js
  4. Node.js로 Github 로그인
  5. URLSearchParams
  6. Fetch API
  7. JWT 토큰 저장 방식 3가지
  8. jwt 디코더


profile
해커 출신 개발자

0개의 댓글