본문 바로가기
JavaScript/Node.js

Node) 익스프레스로 SNS 서비스 만들기 - Passport 모듈로 로그인 구현하기

by 박채니 2023. 1. 2.
안녕하세요, 코린이의 코딩 학습기 채니 입니다.
[Node.js 교과서]의 책을 참고하여 포스팅한 개인 공부 내용입니다.

 

익스프레스로 SNS 서비스 만들기

 

Passport 모듈로 로그인 구현하기

- 우리의 서비스를 이용할 수 있게 해주는 여권 같은 역할 수행

 

Passport 관련 패키지 설치

$ yarn add passport passport-local passport-kakao bcrypt

 

Passport 모듈을 app.ts와 연결시키기

app.ts

import passport from "passport";
import passportConfig from "./passport";

dotenv.config();

const app = express();
passportConfig(); // 패스포트 설정
app.set("port", process.env.PORT || 8081);
app.set("view engine", "html");

...

app.use(
  session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET!,
    cookie: {
      httpOnly: true,
      secure: false,
    },
  })
);

app.use(passport.initialize());
app.use(passport.session());

app.use("/", pageRouter);

 

  • passport.initialize 미들웨어
    • 요청(req 객체)에 passport 설정을 심음
  • passport.session 미들웨어
    • req.session 객체에 passport 정보 저장
    • req.session 객체는 express-session에서 생성하므로, express-session 미들웨어보다 뒤에 위치

 

passport/index.ts

import passport from "passport";
import local from "./localStrategy";
import kakao from "./kakaoStrategy";
import User from "../models/user";

const passportConfig = () => {
  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser<number>((id, done) => {
    User.findOne({ where: { id } })
      .then((user) => done(null, user))
      .catch((err) => done(err));
  });
};

export default passportConfig;

이 때 user.id를 하면 property 'id' does not exist on type 'user'. 오류가 발생합니다.

이를 해결하기 위해선 아래 포스팅 참고!

https://www.inflearn.com/news/154101

 

serializeUser와 deserializeUser 에러나는 분들께 - 인프런 | 강의 공지사항

passport 관련 타이핑이 또 업데이트 되었네요. types/index.d.ts에 다음 코드를 추가하고 import IUser from '../models/user';declare global {   &n...

www.inflearn.com

 

  • passport.serializeUser
    • 로그인 시 실행
    • req.session 객체에 어떤 데이터를 저장할지 정하는 메소드
    • user를 받고 나서 done 함수에 두 번째 인수로 user.id를 넘겨주고 있는 모양
      • done 함수의 첫 번째 인수는 에러가 발생할 때 사용하는 것
      • done 함수의 두 번째 인수는 저장하고 싶은 데이터를 삽입 (사용자 정보 모두 저장 시 용량이 커지므로, id값만 저장하도록)
  • passport.deserializeUser
    • 각 요청마다 실행
    • passport.session 미들웨어가 해당 메소드 호출
    • done의 두 번째 인수로 넣었던 데이터가 deserializeUser 매개변수가 됨 (사용자의 아이디)
    • serializeUser에서 세션에 저장했던 아이디를 받아 데이터베이스에서 사용자 정보를 조회
    • 가져온 정보를 req.user에 저장

 

** 전체 과정 살펴보기

  1. /auth/login 라우터를 통해 로그인 요청이 들어옴
  2. 라우터에서 passport.authenticate 메소드 호
  3. 로그인 전략 수행
  4. 로그인 성공 시 사용자 정보 객체와 함께 req.login 호출
  5. req.login 메소드가 passport.serializeUser 호출
  6. req.session에 사용자 아이디만 저장해서 세션 생성
  7. express-session에 설정한 대로 브라우저에 connect.sid 세션 쿠키 전송
  8. 로그인 완료

** 로그인 이후 과정 살펴보기

  1. 요청이 들어옴
  2. 라우터에 요청이 도달하기 전에 passport.session 미들웨어가 passport.deserializeUser 메소드 호출
  3. connect.sid 세션 쿠키를 읽고 세션 객체를 찾아서 req.session 만듦
  4. req.session에 저장된 아이디로 데이터베이스에서 사용자 조회
  5. 조회된 사용즈 정보를 req.user에 저장
  6. 라우터에서 req.user 객체 사용 가능

passport/index.js의 localStrategy와 kakaoStrategy 파일은 각각 로컬/카카오 로그인 전략에 대한 파일입니다.

Passport는 로그인 시의 동작을 전력이라는 용어로 표현합니다. (로그인 과정 처리)

 

로컬 로그인 구현하기

 

  • 자체적으로 회원 가입 후 로그인 하는 것을 의미

 

회원가입, 로그인, 로그아웃 라우터를 생성해야 하는데 해당 라우터들에는 접근 조건이 있습니다.

로그인한 사용자는 회원가입/로그인 라우터에 접근해선 안되고, 로그인하지 않은 사용자는 로그아웃 라우터에 접근하면 안됩니다.

라우터에 접근 권한을 제어하는 미들웨어가 필요합니다.

 

middlewares/index.js

import { NextFunction, Request, Response } from "express";

exports.isLoggedIn = (req: Request, res: Response, next: NextFunction) => {
  if (req.isAuthenticated()) {
    next();
  } else {
    res.status(403).send("로그인 필요!");
  }
};

exports.isNotLoggedIn = (req: Request, res: Response, next: NextFunction) => {
  if (!req.isAuthenticated()) {
    next();
  } else {
    const message = encodeURIComponent("로그인한 상태입니다.");
    res.redirect(`/?error=${message}`);
  }
};

Passport는 req객체에 isAuthenticaed메소드를 추가합니다.

로그인 중이면 req.Authenticated()가 true, 그렇지 않으면 fasle가 리턴됩니다.

 

routes/page.ts

import express, { NextFunction, Request, Response } from "express";
import { renderProfile, renderJoin, renderMain } from "../controllers/page";
import { isLoggedIn, isNotLoggedIn } from "../middlewares";

const router = express.Router();

router.use((req: Request, res: Response, next: NextFunction) => {
  res.locals.user = req.user;
  res.locals.followerCount = 0;
  res.locals.followingCount = 0;
  res.locals.followingIdList = [];
  next();
});

router.get("/profile", isLoggedIn, renderProfile);
router.get("/join", isNotLoggedIn, renderJoin);

...

사용자 프로필은 로그인한 상태로 접속 가능하므로, isLoggedIn 미들웨어를 사용합니다.

회원 가입은 로그인하지 않은 회원이 이용해야 하므로, isNotLoggedIn 미들웨어를 사용합니다.

 

routes/auth.ts

import express from "express";
import passport from "passport";
import { isLoggedIn, isNotLoggedIn } from "../middlewares";
import { join, login, logout } from "../controllers/auth";

const router = express.Router();

router.post("/join", isNotLoggedIn, join);
router.post("/login", isNotLoggedIn, login);
router.get("/logout", isLoggedIn, logout);

export default router;

 

controllers/auth.ts

import bcrypt from "bcrypt";
import passport from "passport";
import User from "../models/user";
import { Request, Response, NextFunction } from "express";

const join = async (req: Request, res: Response, next: NextFunction) => {
  const { email, nick, password } = req.body;
  try {
    // 기존 존재하는 회원인지 조회
    const exUser = await User.findOne({ where: { email } });
    if (exUser) {
      return res.redirect("/join?error=exist");
    }

    // 새 회원인 경우
    const hash = await bcrypt.hash(password, 12);
    await User.create({
      email,
      nick,
      password: hash,
    });
    return res.redirect("/");
  } catch (error) {
    console.error(error);
    return next(error);
  }
};

const login = (req: Request, res: Response, next: NextFunction) => {
  passport.authenticate(
    "local",
    (authError: Error, user: User, info: { message: string }) => {
      if (authError) {
        console.error(authError);
        return next(authError);
      }
      if (!user) {
        return res.redirect(`/?loginError=${info.message}`);
      }
      return req.login(user, (loginError: Error) => {
        if (loginError) {
          console.error(loginError);
          return next(loginError);
        }
        return res.redirect("/");
      });
    }
  )(req, res, next); // 미들웨어 안에 미들웨어는 (req, res, next)를 붙임
};

const logout = (req: Request, res: Response, next: NextFunction) => {
  req.logout(() => {
    res.redirect("/");
  });
};

export { join, login, logout };
  • join은 회원가입 컨트롤러로, 기존에 같은 이메일로 가입한 회원이 있는지 조회 후 있다면 에러가 포함된 회원가입 페이지로 돌려보냅니다.
    없다면 bcrypt 모듈을 이용해 비밀번호를 암호화하여 처리하였습니다.
  • login은 로그인 컨트롤러로, 로그인 요청이 들어오면 passport.authenticate('local') 미들웨어가 로컬 로그인 전략을 수행합니다.
    전략이 성공하거나 실패하면 authenticate 메소드의 콜백 함수가 실행됩니다.
    콜백함수의 첫 번째 매개변수 값이 있다면 실패한 것이고, 두 번째 매개변수는 사용자 정보입니다. 해당 값이 있다면 성공한 것으로
    req.login 메소드를 호출합니다.
    Passport는 req 객체에 login과 logout 메소드를 추가합니다.
    req.login은 passport.serializeUser를 호출하고, req.login에 제공하는 user 객체가 serializeUser로 넘어가게 됩니다.
  • logout은 로그아웃 컨트롤러로, req.logout 메소드는 req.user 객체와 req.session 객체를 제거합니다.
    req.logout 메소드는 콜백 함수를 인수로 받고, 세션 정보를 지운 후 콜백 함수가 실행됩니다.

로그인 전략을 구현했고, passport-local 모듈에서 Strategy 생성자를 불러와 그 안에 전략을 구현합니다.

 

passport/localStrategy.ts

import passport from "passport";
import bcrypt from "bcrypt";
import User from "../models/user";
import { Strategy as LocalStrategy } from "passport-local";

export default () => {
  passport.use(
    new LocalStrategy(
      {
        usernameField: "email",
        passwordField: "password",
        passReqToCallback: false,
      },
      async (email, password, done) => {
        try {
          const exUser = await User.findOne({ where: { email } });
          if (exUser) {
            const result = await bcrypt.compare(password, exUser.password);
            if (result) {
              done(null, exUser);
            } else {
              done(null, false, { message: "비밀번호가 일치하지 않습니다." });
            }
          } else {
            done(null, false, { message: "가입되지 않은 회원입니다." });
          }
        } catch (error) {
          console.error(error);
          done(error);
        }
      }
    )
  );
};

LocalStrategy 생성자의 첫 번째 인수는 전략에 관한 설정을 하는 곳입니다.

usernameField와 passwordField에는 일치하는 로그인 라우터의 req.body 속성명을 적으면 됩니다.

 

두 번째 인수는 실제 전략을 수행하는 async 함수입니다. 첫 번째 인수에서 넣어준 email과 password는 각각 async 함수의 첫 번째와 두 번째 매개 변수가 됩니다. 세 번째 매개변수인 done 함수는 passport.authenticate의 콜백 함수입니다.

 

카카오 로그인 구현하기

 

  • 인증 과정을 카카오에 맡기는 것을 뜻함
  • 회원가입 절차가 별도 없기 때문에 처음 로그인 시에는 회원 가입 처리를, 두 번째 로그인부터는 로그인 처리를 해야 함

 

passport/kakaoStrategy.ts

import passport from "passport";
import { Strategy as KakaoStrategy } from "passport-kakao";
import User from "../models/user";

interface Profile extends passport.Profile {
  id: string;
  provider: string;
  _raw: string;
  _json: any;
}

export default () => {
  passport.use(
    new KakaoStrategy(
      {
        clientID: process.env.KAKAO_ID!,
        callbackURL: "/auth/kako/callback",
      },
      async (
        accessToken: string,
        refreshToken: string,
        profile: Profile,
        done: (error: any, user?: any, info?: any) => void
      ) => {
        console.log("kako profile", profile);
        try {
          const exUser = await User.findOne({
            where: { snsId: profile.id, provider: "kakao" },
          });
          if (exUser) {
            done(null, exUser);
          } else {
            const newUser = await User.create({
              email: profile._json?.kakao_account?.email,
              nick: profile.displayName,
              snsId: profile.id,
              provider: "kakao",
            });
            done(null, newUser);
          }
        } catch (error) {
          console.error(error);
          done(error);
        }
      }
    )
  );
};

clientID는 카카오에서 발급해주는 아이디로, 아이디를 발급받아 .env 파일에 넣어 관리할 예정입니다.

callbackURL은 카카오로부터 인증 결과를 받을 라우터 주소입니다.

 

기존 카카오를 통해 회원 가입한 사용자가 있는지 조회한 후, 있다면 사용자 정보와 함께 done함수를 호출하고 전략을 종료합니다.

없다면 회원가입을 진행하고, 카카오에서 보내준 profile 객체에서 원하는 정보를 꺼내와 회원 가입 처리를 합니다.

 

카카오 로그인 라우터 생성

routes/auth.ts

...

router.get("/logout", isLoggedIn, logout);

// GET /auth/kakao
router.get("/kakao", passport.authenticate("kakao"));

// GET /auth/kakao/callback
router.get(
  "/kakao/callback",
  passport.authenticate("kakao", {
    failureRedirect: "/?loginError=카카오로그인 실패",
  }),
  (req, res) => {
    res.redirect("/"); // 성공 시에는 /로 이동
  }
);

export default router;

GET /auth/kakao 로 접근하면 카카오 로그인 과정이 시작됩니다. GET /auth/kakao에서 로그인 전략을 수행하는데, 처음에는 카카오 로그인 창으로 리다이렉트 합니다.

그 창에서 로그인 후 성공 여부 결과를 GET /auth/kakao/callback으로 받고 해당 라우터에서는 카카오 로그인 전략을 다시 수행합니다.

카카오 로그인은 로그인 성공 시 내부적으로 req.login을 호출하기 때문에 직접 호출할 필요가 없습니다.

 

app.ts (라우터 연결)

...
import authRouter from "./routes/auth";

...

app.use(passport.initialize());
app.use(passport.session());

app.use("/", pageRouter);
app.use("/auth", authRouter);

...

 

카카오 개발자 계정과 카카오 로그인용 애플리케이션 등록하기

https://inpa.tistory.com/entry/NODE-%F0%9F%93%9A-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-Passport-%EA%B5%AC%ED%98%84

 

[NODE] 📚 카카오 로그인 (passport-kakao) ✈️ 구현

카카오 로그인 OAuth 신청 카카오 로그인을 위해서는 카카오 개발자 계정과 로그인용 애플리케이션 등록이 필요하다. https://developers.kakao.com 에 접속하여 개발자 계정을 만들고 아이디를 만들어주

inpa.tistory.com