Backend ๐Ÿ“š/Node.js

[Node, React]Instagram Clone - 16. ์นด์นด์˜คํ†ก ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„..์„ ๋น™์žํ•œ ์นด์นด์˜ค OAuth2 ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด ๊ตฌํ˜„

leejaejae 2025. 1. 17. 01:10

์—ฌํƒœ ํ•œ ๊ฒƒ ์ค‘์— ์ œ์ผ ์–ด๋ ค์› ๋‹ค.. ์นด์นด์˜คํ†ก ๋กœ๊ทธ์ธ ์ž์ฒด๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์€ ์–ด๋ ต์ง€ ์•Š์•˜๋Š”๋ฐ ๊ธฐ์กด์˜ ๋กœ๊ทธ์ธ ํ™•์ธ ๋กœ์ง(๋‚œ ์ฟ ํ‚ค๋กœ ๋„˜๊ฒจ์„œ ๊ด€๋ฆฌํ•จ)์ด๋ž‘ ์นด์นด์˜ค ๋กœ๊ทธ์ธ ํ™•์ธ ๋กœ์ง์„ ํ†ตํ•ฉํ•˜๋Š” ๋ฐ ์• ๋ฅผ ๋จน์—ˆ๋‹ค. ์นดํ†ก ๋กœ๊ทธ์ธ ๊ตฌํ˜„์— ๊ฑธ๋ฆฐ ์‹œ๊ฐ„์ด 1์‹œ๊ฐ„์ด๋ผ๋ฉด ๋กœ๊ทธ์ธ ํ™•์ธ ๋กœ์ง ํ†ตํ•ฉ์€ ์ฒด๊ฐ 12์‹œ๊ฐ„ ๊ฑธ๋ฆฐ ๋Š๋‚Œ.. ์•„๋ฌดํŠผ ์ด๊ฑด ๊ธฐ๋กํ•˜์ง€ ์•Š์œผ๋ฉด ๋‘๊ณ ๋‘๊ณ  ํ›„ํšŒํ•  ๊ฒƒ ๊ฐ™์•„์„œ ์˜ค๋žœ๋งŒ์— ํฌ์ŠคํŒ…์„ ํ•ด๋ณธ๋‹ค. ๊ฑฐ๋‘์ ˆ๋ฏธํ•˜๊ณ  ์‹œ์ž‘ํ•ด ๋ณด๊ฒ ๋‹ค.


1. ์นด์นด์˜ค ๋กœ๊ทธ์ธ ๊ตฌํ˜„

์ถœ์ฒ˜: Kakao Developers

  • Kakao Developers ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜๋ฉด ๋กœ๊ทธ์ธ๊นŒ์ง€ ์–ด๋ ต์ง€ ์•Š๊ฒŒ ๊ตฌํ˜„์ด ๊ฐ€๋Šฅํ•˜๋‹ค.(์ฐจ๊ทผ์ฐจ๊ทผ ํ•˜๋ฉด ๋)

1) ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ(Kakao.jsx)

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

const Kakao = ({ setIsAuthenticated }) => {
  const navigate = useNavigate();

  useEffect(() => {
    const handleKakaoLoginCallback = async () => {
      const url = new URL(window.location.href);
      const code = url.searchParams.get("code");

      if (!code) {
        console.log("์นด์นด์˜ค ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ๋ Œ๋”๋ง ์ค‘...");
        return;
      }

      try {
        const response = await axios.post(
          "http://localhost:5001/auth/kakao/callback",
          { code },
          { withCredentials: true }
        );
        
        const jwtToken = response.data.jwtToken;

        if (jwtToken) {
          axios.defaults.headers.common["authorization"] = `Bearer ${jwtToken}`;
          alert("๋กœ๊ทธ์ธ ์„ฑ๊ณต");
          setIsAuthenticated(true);
          navigate("/");
        } else {
          throw new Error("ํ† ํฐ ๋ณตํ˜ธํ™” ์‹คํŒจ");
        }
      } catch (err) {
        alert("๋กœ๊ทธ์ธ ์‹คํŒจ");
        console.error("Kakao ์ธ์ฆ ์‹คํŒจ:", err.message);
        navigate("/auth/login");
      }
    };

    handleKakaoLoginCallback();
  }, [setIsAuthenticated]);
};

export default Kakao;
  1. ๊ณต์‹๋ฌธ์„œ ๋”ฐ๋ผ ๊ฐ€๋‹ค๊ฐ€..(~๋ฐฑ์—”๋“œ๋กœ ์ธ์ฆ ์ฝ”๋“œ ์ „์†ก๊นŒ์ง€)
  2. withCredentials: true๋ฅผ ์„ค์ •ํ•ด ์ฟ ํ‚ค์— ์ €์žฅ๋œ ์ธ์ฆ ์ •๋ณด๋ฅผ ํ™œ์šฉ
  3. ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ JWT ์‘๋‹ต์„ ๋ฐ›๊ณ  axios.defaults.headers.common์— Bearer ํ† ํฐ์„ ์ถ”๊ฐ€ํ•ด ์ดํ›„์˜ ๋ชจ๋“  ์š”์ฒญ์— ์ธ์ฆ ํ—ค๋”๋ฅผ ํฌํ•จํ•˜๋„๋ก ์„ค์ •
  4. JWT ํ† ํฐ์ด ์กด์žฌํ•˜๋ฉด, ์ธ์ฆ ์„ฑ๊ณต ๋ฉ”์„ธ์ง€ ์ถœ๋ ฅํ•˜๊ณ  ์ธ์ฆ ์ƒํƒœ ์—…๋ฐ์ดํŠธ(setIsAuthenticated(true))ํ•œ ํ›„ ํ™ˆํŽ˜์ด์ง€๋กœ ์ด๋™
  5. ๊ทธ๋ฆฌ๊ณ  ๋ผ์šฐํ„ฐ์— ๊ฒฝ๋กœ ์ถ”๊ฐ€

2) ์„œ๋ฒ„ ์ฝ”๋“œ(kakao.js)

const express = require("express");
const axios = require("axios");
const jwt = require("jsonwebtoken");

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

const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");

router.use(bodyParser.urlencoded({ extended: true }));
router.use(bodyParser.json());
router.use(cookieParser());

const KAKAO_TOKEN_URL = process.env.KAKAO_TOKEN_URL;
const KAKAO_USER_INFO_URL = process.env.KAKAO_USER_INFO_URL;
const JWT_SECRET = process.env.JWT_SECRET;
const KAKAO_CLIENT_ID = process.env.KAKAO_CLIENT_ID;
const REDIRECT_URI = process.env.REDIRECT_URI;

router.post("/callback", async (req, res) => {
  const { code } = req.body;
  if (!code) {
    return res.status(400).send("Kakao code is missing");
  }

  try {
    const tokenResponse = await axios.post(KAKAO_TOKEN_URL, null, {
      params: {
        grant_type: "authorization_code",
        client_id: KAKAO_CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        code,
      },
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
    });

    if (!tokenResponse.data || !tokenResponse.data.access_token) {
      throw new Error("Failed to get access token");
    }
    const { access_token } = tokenResponse.data;

    const userResponse = await axios.get(KAKAO_USER_INFO_URL, {
      headers: { Authorization: `Bearer ${access_token}` },
    });

    const kakaoUser = userResponse.data;
    const email = kakaoUser?.kakao_account?.email || null;
    const nickname = kakaoUser?.properties?.nickname || "Unknown";

    const user = {
      kakaoId: kakaoUser.id,
      email,
      nickname,
    };

    const jwtToken = jwt.sign(user, JWT_SECRET, { expiresIn: "1h" });

    res.cookie("x_auth", jwtToken, {
      httpOnly: true,
      maxAge: 3600000,
    });

    return res.status(200).json({
      message: "Kakao login success",
      loginSuccess: true,
      jwtToken,
      user: {
        kakaoId: user.kakaoId,
        email: user.email,
        nickname: user.nickname,
      },
    });
  } catch (err) {
    console.error(err);
    res.status(500).send("Kakao ์ธ์ฆ ์‹คํŒจ");
  }
});

module.exports = router;
  1. ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ code๋ฅผ POST ์š”์ฒญ์œผ๋กœ ์ „๋‹ฌ๋ฐ›์•„ ํ™•์ธ, ์—†์„ ๊ฒฝ์šฐ 400 ์—๋Ÿฌ
  2. ์นด์นด์˜ค ์„œ๋ฒ„์— ์—‘์„ธ์Šค ํ† ํฐ ์š”์ฒญํ•˜๊ณ , ๋ฐ›์€ ํ† ํฐ์œผ๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด ์š”์ฒญ
  3. ์‚ฌ์šฉ์ž ์ •๋ณด ๊ธฐ๋ฐ˜์œผ๋กœ JWT ํ† ํฐ ์ƒ์„ฑ
  4. ์ƒ์„ฑ๋œ JWT ํ† ํฐ์„ httpOnly ์ฟ ํ‚ค๋กœ ์„ค์ •

 

2. ์นด์นด์˜ค OAuth2 ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด ๊ตฌํ˜„

  • ํด๋ผ์ด์–ธํŠธ์—์„œ ๊ธฐ๋ณธ ๋กœ๊ทธ์ธ์ด๋ž‘ ์นด์นด์˜ค ๋กœ๊ทธ์ธ์„ ํ†ตํ•ฉํ•ด์„œ ์ธ์ฆ ์ƒํƒœ๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ํ•ด์•ผ ํ•˜๋Š”๋ฐ ๊ทธ๊ฒŒ ์•ˆ๋˜์„œ ์นด์นด์˜ค ๋กœ๊ทธ์ธํ•˜๋ฉด ๋กœ๊ทธ์ธ ์ƒํƒœ์ธ ๊ฑธ ์ธ์‹์„ ๋ชปํ•ด์„œ ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•œ ๋‹ค๋ฅธ ํŽ˜์ด์ง€์— ์ ‘๊ทผ์ด ์•ˆ๋˜๋Š” ์ด์Šˆ๊ฐ€ ์ƒ๊ฒผ๋‹ค. 
  • ๋ฌธ์ œ๋Š” ๋ฏธ๋“ค์›จ์–ด ๋ถ€๋ถ„์ด์˜€๋‹ค.
  • ์ฒ˜์Œ์—” ๊ทธ๋ƒฅ JWT๋กœ ๋งŒ๋“ค์–ด์„œ ์“ฐ๋ฉด ๊ธฐ์กด์˜ ๋กœ์ง์— ๋ฐ”๋กœ ์ ์šฉ๋˜๋Š” ์ค„ ์•Œ์•˜๋Š”๋ฐ ์•Œ๊ณ ๋ณด๋‹ˆ ๊ทธ ๋‚ด๋ถ€ ํ˜•ํƒœ๋„ ๋‹ค๋ฅด๊ณ  ๋ญ”๊ฐ€ ๋ณต์žกํ•˜๊ฒŒ ์–ฝํ˜€์žˆ์—ˆ๋‹ค.
  • ๊ทธ๋ž˜์„œ ํ•˜๋‚˜ํ•˜๋‚˜ ๋กœ๊ทธ๋ฅผ ์ฐ์–ด๋ณด๋ฉด์„œ ๋œฏ์–ด ๊ณ ์ณ๋ณด์•˜๋‹ค..(์ด๊ฒŒ ์ง„์งœ 10์‹œ๊ฐ„์€ ๋„˜๊ฒŒ ๊ฑธ๋ฆฐ ๋“ฏ..ใ…‹ใ… ใ… )

1) ์นด์นด์˜ค ํ† ํฐ ๊ฒ€์ฆ ํ•จ์ˆ˜ (verifyKakaoToken)

const verifyKakaoToken = async (token) => {
  try {
    const response = await axios.post(
      "https://kapi.kakao.com/v2/user/me",
      {},
      {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      }
    );

    if (response.data) {
      return {
        isAuth: true,
        user: {
          _id: response.data.id,
          email: response.data.kakao_account.email,
          username: response.data.properties.nickname,
        },
      };
    } else {
      return { isAuth: false };
    }
  } catch (error) {
    console.error("์นด์นด์˜ค ํ† ํฐ ๊ฒ€์ฆ ์—๋Ÿฌ:", error);
    return { isAuth: false };
  }
};
  • ์ด ํ•จ์ˆ˜๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ „๋‹ฌํ•œ ์นด์นด์˜ค ์•ก์„ธ์Šค ํ† ํฐ์„ ์ด์šฉํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฒ€์ฆํ•จ
  • ์นด์นด์˜ค API์— ์š”์ฒญํ•ด ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ค๋ฉฐ, ์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ผ ๊ฒฝ์šฐ ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•จ

2) User ๋ชจ๋ธ ์ˆ˜์ •(findOrCreateByKakaoId)

userSchema.statics.findOrCreateByKakaoId = async function (kakaoId, userData) {
  const kakaoIdStr = kakaoId.toString();
  // console.log("์นด์นด์˜คId ๋ฌธ์ž์—ด ๋ณ€ํ™˜", kakaoIdStr);

  const user = await this.findOne({ kakaoId: kakaoIdStr });
  // console.log("๋ชจ๋ธ์—์„œ ์ฐพ์€ user:", user);

  if (user) {
    return user;
  } else {
    // console.log("์‚ฌ์šฉ์ž๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ, ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž ์ƒ์„ฑ.");
    const newUser = new this({
      kakaoId: kakaoIdStr,
      user_id: userData.user_id || kakaoIdStr,
      email: userData.email,
      name: userData.nickname,
      profile_image: userData.profile_image || null,
    });

    return newUser
      .save()
      .then((savedUser) => {
        console.log("์ €์žฅ๋œ ์‚ฌ์šฉ์ž:", savedUser);
        return savedUser;
      })
      .catch((err) => {
        console.error("์‚ฌ์šฉ์ž ์ €์žฅ ์‹คํŒจ:", err);
        throw new Error("์‚ฌ์šฉ์ž ์ €์žฅ ์‹คํŒจ");
      });
  }
};
  • ์ฃผ์–ด์ง„ kakaoId๋กœ ์‚ฌ์šฉ์ž๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  ์—†์œผ๋ฉด ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•จ

3) ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด ์ˆ˜์ •(auth.js)

// ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด
let auth = async (req, res, next) => {
  const jwtToken = req.cookies.x_auth;
  const kakaoToken = req.headers.authorization?.split(" ")[1];
  const secretKey = process.env.JWT_SECRET;

  // JWT ์ธ์ฆ ์ฒ˜๋ฆฌ
  if (jwtToken) {
    if (!secretKey) {
      return res.status(500).json({
        isAuth: false,
        message: "์„œ๋ฒ„ ์„ค์ • ์˜ค๋ฅ˜: ๋น„๋ฐ€ ํ‚ค๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.",
      });
    }

    try {
      const decoded = jwt.verify(jwtToken, secretKey);
      console.log("decoded", decoded);

      // ํ† ํฐ ๋งŒ๋ฃŒ ํ™•์ธ
      if (decoded.exp < Date.now() / 1000) {
        return res.status(401).json({
          isAuth: false,
          message: "ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.",
        });
      }

      let user;
      if (decoded.kakaoId) {
        // ์นด์นด์˜ค
        const kakaoId = decoded.kakaoId.toString();
        user = await User.findOrCreateByKakaoId(kakaoId, {
          user_id: decoded.user_id,
          email: decoded.email,
          nickname: decoded.nickname,
          profile_image: decoded.profile_image || null,
        });
      } else if (decoded._id) {
        // ์ผ๋ฐ˜ ์œ ์ €
        user = await User.findById(decoded._id);
      } else {
        return res.status(401).json({
          isAuth: false,
          message: "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.",
        });
      }

      // console.log("user ํ™•์ธ", user);

      if (!user) {
        return res.status(401).json({
          isAuth: false,
          message: "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.",
        });
      }

      req.token = jwtToken;
      req.user = user;
      return next();
    } catch (err) {
      console.error("JWT ๊ฒ€์ฆ ์‹คํŒจ:", err);
      return res.status(401).json({
        isAuth: false,
        message: "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.",
      });
    }
  }

  // ์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ธ์ฆ ์ฒ˜๋ฆฌ
  if (kakaoToken) {
    const kakaoResult = await verifyKakaoToken(kakaoToken);
    if (kakaoResult.isAuth) {
      req.user = kakaoResult.user;
      return next();
    } else {
      return res.status(401).json({
        isAuth: false,
        message: "์นด์นด์˜ค ์ธ์ฆ ์‹คํŒจ",
      });
    }
  }

  // JWT์™€ ์นด์นด์˜ค ํ† ํฐ ๋ชจ๋‘ ์—†์„ ๊ฒฝ์šฐ
  console.error("์ฟ ํ‚ค ๋˜๋Š” ํ—ค๋”์—์„œ ์ธ์ฆ ํ† ํฐ์ด ๊ฐ์ง€๋˜์ง€ ์•Š์Œ");
  return res.status(401).json({
    isAuth: false,
    message: "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.",
  });
};
  • ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ JWT ๋˜๋Š” ์นด์นด์˜ค ํ† ํฐ์„ ๊ฒ€์ฆํ•˜๊ณ  ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅด req.user์— ์ €์žฅํ•˜๋Š” ๋ฏธ๋“ค์›จ์–ด
  • ๊ฐ€์žฅ! ์–ด๋ ค์› ๋˜ ๋ถ€๋ถ„์ธ๋ฐ, ์ผ๋ฐ˜ ๋กœ๊ทธ์ธ๊ณผ ์นด์นด์˜ค ๋กœ๊ทธ์ธ์„ ๊ตฌ๋ณ„ํ•ด์•ผ ํ•˜๋Š” ์ ์ด ์–ด๋ ค์› ์Œ
  • ๋ฌด์—‡๋ณด๋‹ค ์นด์นด์˜ค API ํ˜ธ์ถœํ–ˆ์„๋•Œ ์‘๋‹ต ๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ์ด ์ œ๋Œ€๋กœ ์ด๋ค„์ง€์ง€ ์•Š์•„์„œ undefined ์˜ค๋ฅ˜๊ฐ€ ๊ณ„์† ๋ฐœ์ƒํ•จ

3. ๋Š๋‚€ ์ 

  • ์•ฝ๊ฐ„ ์“ฐ๊ณ  ๋‚˜๋‹ˆ๊น ๋ณ„๊ฑฐ ์•„๋‹Œ ๊ฑฐ ๊ฐ™๋‹ค.. ์ฉœ ์ด๋ฏธ ์ฝ”๋“œ ํ๋ฆ„์ด ๋จธ๋ฆฟ์† ์— ์žˆ์–ด์„œ ๊ทธ๋Ÿฐ ๊ฒƒ ๊ฐ™์Œ. ์ œ๋Œ€๋กœ๋œ ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…์„ ์จ๋ณด๊ณ  ์‹ถ์—ˆ๋Š”๋ฐ ์•„์‰ฝ๋‹ค. 
  • ์•ž์œผ๋กœ๋Š” Redis๋„ ์จ์„œ ์„ธ์…˜ ๊ด€๋ฆฌ๋ฅผ ์ œ๋Œ€๋กœ ํ•ด๋ณด๊ณ  ์‹ถ๋‹ค.