(개발일지) Passport.js+Next-auth로 로그인 구현하기

한중일 개발자·2024년 2월 23일

Engage.pub 개발일지

목록 보기
2/2

본 셋업에는 제로초님의 노드버드 강좌와 SNS 서비스 만들기 강좌가 큰 도움이 되었습니다!

전 프로젝트에선 AWS Cognito를 유저 풀로 사용하고, Amplify UI까지 사용하여 로그인 과정에 크게 힘을 안들여도 되었는데 이번 졸업논문 프로젝트에선 풀스택으로 로그인을 구현해야 해서 애를 좀 썼다.

백엔드

Passport.js 셋업

Passport is authentication middleware for Node.js. Extremely flexible and modular, Passport can be unobtrusively dropped in to any Express-based web application. A comprehensive set of strategies support authentication using a username and password, Facebook, Twitter, and more.

Passport.js는 Node.js에서 쓰일 수 있는 인증 미들웨어다. 빠르게 Express.js에서 이메일+비밀번호를 사용한 로그인을 구현하기 위하여 Passport.js의 local strategy를 사용했다.

passport/local.js

const passport = require("passport");
const { Strategy: LocalStrategy } = require("passport-local");
const bcrypt = require("bcrypt");
const { User } = require("../models");

module.exports = () => {
    passport.use(new LocalStrategy({
      usernameField: 'email',
      passwordField: 'password',
    }, async (email, password, done) => {
      try {
        const user = await User.findOne({
          where: { email }
        });
        if (!user) {
          return done(null, false, { reason: 'User does not exist.' });
        }
        const result = await bcrypt.compare(password, user.password);
        if (result) {
          return done(null, user);
        }
        return done(null, false, { reason: 'Wrong password.' });
      } catch (error) {
        console.error(error);
        return done(error);
      }
    }));
  };

백엔드 local strategy다. username으로 이메일을 사용하고, 유저가 존재하는지 확인 후 bcrypt를 통해 해쉬되어 db에 저장된 비밀번호와 유저가 입력한 비밀번호를 비교하고, 맞다면 Sequelize의 user 객체를 반환해주는 구조다.

passport/index.js

const passport = require("passport");
const local = require("./local");
const { User } = require("../models");

module.exports = () => {
  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser(async (id, done) => {
    try {
      const user = await User.findOne({
        where: { id },
      });
      done(null, user);
    } catch (error) {
        console.log(error);
        done(error);
    }
  });

  local();
};

serializeUser는 세션에 유저의 id 정보를 담아준다. 이후 deserializeUser는 세션의 id를 인자로 받아 해당 id의 유저가 존재하는지 확인 후 유저 객체를 반환한다. 백엔드에서 세션을 통해 유저를 파악하는 용도로 쓰인다 보면 된다.

middleware.js

exports.isLoggedIn = (req, res, next) => {
    if(req.isAuthenticated()) {
        next();
    }
    else {
        res.status(401).send('Login required.');
    }
};

exports.isNotLoggedIn = (req, res, next) => {
    if(!req.isAuthenticated()) {
        next();
    }
    else {
        res.status(401).send('You are logged in.');
    }
};

여기에서 우선 들어오는 리퀘스트에 인증 세션이 유효한지 확인한다.

user.js

const { isLoggedIn, isNotLoggedIn } = require("./middlewares");

// Login
router.post("/login", isNotLoggedIn, (req, res, next) => {
  passport.authenticate("local", (err, user, info) => {
    if (err) {
      console.log(err);
      return next(err);
    }
    if (info) {
      return res.status(401).send(info.reason);
    }
    return req.login(user, async (loginErr) => {
      if (loginErr) {
        console.log(loginErr);
        return next(loginErr);
      }
      // Exclude password from the user info returned
      const userInfo = await User.findOne({
        where: { id: user.id },
        attributes: {
          exclude: ["password"],
        },
      });
      return res.status(200).json(userInfo);
    });
  })(req, res, next);
});

// Logout
router.post("/logout", isLoggedIn, (req, res) => {
  req.logout();
  req.session.destroy();
  res.status(200).send("Logout Succeeded");
});

최종적으로 여기서 middleware를 통해 로그인 여부를 확인하고, 로그인이 안되있어야 하는 로그인에는 isNotLoggedIn을, 반대로 로그아웃에는 isLoggedIn을 인자로 두어 위처럼 로그인, 로그아웃 엔드포인트를 작성한다.

프론트엔드

Next-auth (Auth.js) 셋업

Auth.js is a complete open-source authentication solution for web applications.

백엔드 구축 이후, Next.js 프론트에서의 인증을 쉽게 해주는 라이브러리다.

auth.ts

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import cookie from "cookie";

export const {
  handlers: { GET, POST },
  auth,
  signIn,
} = NextAuth({
  trustHost: true,
  pages: {
    signIn: "/login",
    newUser: "/signup",
  },
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        const authResponse = await fetch(
          `${process.env.NEXT_PUBLIC_BASE_URL}/user/login`,
          {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              email: credentials.username,
              password: credentials.password,
            }),
          }
        );

        // Setting cookies sent from the backend
        let setCookie = authResponse.headers.get("Set-Cookie");
        console.log(setCookie);
        if (setCookie) {
          const parsed = cookie.parse(setCookie);
          console.log(parsed);
          cookies().set("connect.sid", parsed["connect.sid"], {
            domain: parsed.Domain,
            secure: process.env.APP_ENV === 'production'
          });
        }

        // authResponse.ok is still ok even when status is 401, so handling that
        if (!authResponse.ok || authResponse.status === 401) {
          return null;
        }

        const user = await authResponse.json();

        return {
          id: user.email,
          name: user.nickname,
          ...user,
        };
      },
    }),
  ],
});

Auth.js의 configuration file이다. 클라이언트 사이드에서 인증 상태를 관리할수 있게 하는 초석이라 보면 된다. 위 코드는 크게 아래 부분으로 나누어 볼 수 있다:

  • 로그인과 회원가입을 처리하는 페이지 루트를 추가한다.
  • API의 /user/login 엔드포인트로 이메일과 비밀번호를 POST한다.
  • 백엔드에서 connect.sid로 시작하는 쿠키를 보내줄건데, 이를 돌아온 응답의 Set-Cookie 헤더에서 가져오고, 설정한다.
  • 현재 사용중인 next-auth의 버전인 5.0.0-beta.13에 버그가 있는데, response.ok 값이 로그인에 실패해도 true로 떠서 authResponse.status === 401을 추가하여 핸들링하고 있다.
  • 최종적으로 id와 name에 유저 이메일과 nickname을 할당한다. DefaultUser를 상속받고 있어서 id 인자와 name 인자가 필수적으로 들어가야 하는데, 여기에 내가 원하는 속성으로 바꿀 수 있다.

/api/[...nextauth]/route.ts

export { GET, POST } from '@/auth';

이 한줄로, /api/auth/*로 가는 리퀘스트를 Auth.js가 핸들링하게 할 수 있다.

AuthSession.tsx

'use client';
import { SessionProvider } from "next-auth/react";

type Props = ({
  children: React.ReactNode;
});

export default function AuthSession({ children }: Props) {
  return <SessionProvider>{children}</SessionProvider>;
}

작성 이후 layout.tsx에서 컴포넌트들을 <AuthSession>으로 감싸면 클라이언트 컴포넌트 측에서 인증 세션 상태에 접근 가능하다.

"use client";
import { useSession } from "next-auth/react";

const { data: me, status } = useSession();
...
<Avatar nickname={me?.user?.name as string} />

같은 식으로.

middleware.ts

import { auth } from "./auth";
import { NextResponse } from "next/server";

export async function middleware() {
  const session = await auth();
  if (!session) {
    return NextResponse.redirect("http://localhost:3000/login");
  }
}

// See "Matching Paths" below to learn more
export const config = {
  matcher: ["/talk"],
};

최종적으로, middleware.ts를 사용해 현 세션 상태를 확인하고, 로그인이 필요한 matching path면 로그인 화면으로 redirect 할 수 있다.

문제 해결

문제: 배포 이후 로그아웃시 세션 프론트->백으로 쿠키 전송이 안됨

// Setting cookies sent from the backend
let setCookie = authResponse.headers.get("Set-Cookie");
  if (setCookie) {
  const parsed = cookie.parse(setCookie);
  cookies().set("connect.sid", parsed["connect.sid"], parsed);
}

원래 코드는 이 부분에서 서버에서 쿠키를 백엔드에서 받아서 connect.sid라는 이름으로 저장해두고, 로그아웃 시 해당 쿠키를 다시 백엔드에 전달해주는 방식이었다. 로컬호스트에선 정상적으로 됐지만 배포 이후 로그아웃시... 세션 쿠키 자체가 아예 전송되지 않는 문제가 발생했다!

해결: 쿠키 도메인 설정

결론적으로 쿠키의 도메인이 설정되지 않은채로 와서 그랬다. 크롬은 쿠키의 도메인 설정이 되어있지 않은데 양단의 도메인이 다르면 아예 쿠키 전송을 하지 않는다. 내 프론트는 (dev).engage.pub인데 백엔드는 api.engage.pub고, 프론트에서 별다른 도메인 설정을 안하면 쿠키의 도메인은 (dev).engage.pub로 저장되어 쿠키 전송이 되고 있지 않았던 것이다.

// App.js- 백엔드
app.use(session({
  saveUninitialized: false,
  resave: false,
  secret: process.env.COOKIE_SECRET,
  proxy: true,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 1000 * 60 * 60 * 5,
    domain: process.env.NODE_ENV === 'production' && '.engage.pub'
  },
}));
// Setting cookies sent from the backend
let setCookie = authResponse.headers.get("Set-Cookie");
  if (setCookie) {
  const parsed = cookie.parse(setCookie);
  cookies().set("connect.sid", parsed["connect.sid"], {
    domain: parsed.Domain,
    secure: process.env.APP_ENV === 'production'
  });
}

백과 프론트 코드를 위처럼 바꿔주면 백에서 production이라면 도메인을 루트 도메인으로 설정해주는 쿠키를 보내주고, 프론트에선 해당 도메인을 parse하고 쿠키로 설정해주니 해결되었다.

profile
한국에서 태어나고, 중국 베이징에서 대학을 졸업하여, 일본 도쿄에서 개발자로 일하고 있습니다. 유창한 한국어, 영어, 중국어, 일본어와 약간의 자바스크립트를 구사합니다.

0개의 댓글