Node.js/Express.js

[Toy Project - Omuk] 10. 사용자 인증(Redis, Passport, Express)

턴태 2022. 9. 6. 15:53

Passport

세션을 내부적으로 처리하여 로그인 기능을 대신 해줍니다. 그냥 로그인도 가능하지만, 카카오·구글·페이스북·애플 등 SNS 계정을 통해 로그인 하고자 할 때, 유용하게 사용할 수 있습니다.

 

먼저 passport 모듈과 서버 자체적으로 로그인할 때 사용하기 위해 로컬 전략을 사용해야 하므로 passport-local 을 설치해줍니다.

npm i passport passport-local

이제 passport를 사용하기 위해 루트 디렉터리에서 passport 폴더를 생성합니다. 그리고 메인 파일인 index.js 파일과 로컬 전략을 작성하기 위해 localStrategy.js 를 생성합니다.

 

먼저 로컬 전략을 세우기 위해 localStrategy.js 파일에 내용을 작성하겠습니다. 이때의 전략은 간단하게 어떻게 로그인을 구현할지입니다.

import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";

import User from "../models/user.js";

passport와 passport-local 모듈을 불러옵니다. 그리고 유저 모델도 불러옵니다. 이제, 패스포트를 통해 로그인 과정을 구현합니다.

export default () => {
  passport.use(
    new LocalStrategy(
      {
        // input 태그의 name
        usernameField: "email",
        passwordField: "password",
      },

로컬 전략 인스턴스를 새로 생성해주고, 첫 번째 매개변수에 usernameField와 passowrdField의 값을 객체로 넣어주어야 합니다. 이때 각각 username과 password로 쓰일 input 태그의 name 값에 맞춰서 값을 넣어줍니다.

async (email, password, done) => {
    if (email || password) {
      try {
        const exUser = await User.findOne({ where: { email } });
        if (exUser) {
          if (password === exUser.password) {
            done(null, exUser);
          } else {
            done(null, false, { message: "비밀번호가 일치하지 않습니다." });
          }
        } else {
          done(null, false, { message: "가입하지 않은 회원입니다." });
        }
      } catch (err) {
        console.error(err);
        done(err);
      }
    }
}

위의 과정은 로그인 과정을 작성한 것입니다. 여기서는 password를 그대로 썼는데, 암호화를 통해서 둘을 비교하는 것이 더 보안적으로 좋기 때문에 향후 bcrypt 모듈을 사용하여 패스워드를 암호화하고 복호화하여 로그인의 보안의 조금 더 강화하겠습니다.

 

이제 index.js 파일을 작성하겠습니다.

 

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

앞서 작성한 로컬 전략 파일을 임포트해서 가져옵니다.

export default () => {
  /** 사용자 세션 작성 */
  passport.serializeUser((user, done) => {
    done(null, user.id);
  });
  /** 사용자 세션 해석 */
  passport.deserializeUser((id, done) => {
    User.findOne({ where: { id } })
    	.then((user) => done(null, user))
        .catch((err) => done(err));
  });

  local();
};

사용자 세션을 작성할 수 있도록 serializeUser 메서드를 사용해서 사용자의 세션을 작성하도록 합니다. 그리고 사용자 세션을 읽어주기 위해서 deserializeUser 메서드를 사용합니다.

 

이제 메인 app.js 파일에서 불러오고 실행합니다.

// 패스포트 설정
import passportConfig from "./passport/index.js";

const app = express();
passportConfig();

...

// 7. 패드포트
app.use(passport.initialize());
app.use(passport.session());

그리고, 세션이 작성되어 Redis 저장소에 저장될 수 있도록 redis와 연결시켜줍니다.

 

Redis

Redis (Remote Dictionary Server). 키와 밸류를 사용하는 저장소로 데이터베이스나 캐시 저장소 등으로 사용되며 빠른 속도가 장점인 원격 저장소입니다.

 

https://redis.io/docs/getting-started/installation/

 

Installing Redis

Install Redis on Linux, macOS, and Windows

redis.io

저는 Windows OS를 사용하기 때문에 WSL2를 통해 설치하겠습니다.

 

우분투 환경에서

 

curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list

sudo apt-get update
sudo apt-get install redis

를 입력한 후에 서버를 실행합니다.

 

sudo service redis-server start

이제 다시 VSCode로 돌아가서 레디스 환경을 설정해줍니다.

 

필요한 패키지는 redis, connect-redis 입니다.

npm i redis connect-redis

redis는 클라이언트를 생성하기 위해서, connect-redis는 세션을 레디스에 저장하기 위해서 설치합니다.

// 레디스 설정
import { createClient } from "redis";
import rs from "connect-redis";
const redisClient = createClient({ legacyMode: true });
const RedisStore = rs(expressSession);
redisClient.connect().catch(console.error);

레디스 패키지에서 createClient를 가져오고 connect-redis로 익스프레스 세션을 넣어줍니다.

이때, redisClient에서 옵션을 legacyMode로 설정한 이유는, redis와 connect-redis 패키지가 버전이 업데이트 되면서 서로 잘 맞지 않아서 계속 에러가 발생하기 때문입니다.

 

이제 세션의 옵션에서 저장소를 레디스 저장소로 설정하면 됩니다.

app.use(
  expressSession({
    resave: false,
    secret: process.env.COOKIE_SECRET,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: false,
    },
    store: new RedisStore({
      client: redisClient,
      host: "127.0.0.1",
      port: 6379,
    }),
  })
);

우분투에서 레디스 서버를 실행하며, 해당 포트는 6379입니다. 우분투에서 sudo service redis-server start 한 후에 redis-cli 명령을 입력하면 포트가 보입니다. 혹은 netstat으로 확인해도 됩니다.

 

이제 회원가입 후에 로그인 하면 세션 쿠키가 주어지면서 세션 데이터를 자유롭게 사용할 수 있습니다.

 

그런데 Passport의 문법이 처음 보면 너무나도 이해하기가 난해합니다. 특히 done에서 각 인자가 어떻게 전달이 되고 어떻게 사용되는지, 어떻게 세션이 부여되는지 헷갈립니다.

 

실제로 로그인을 다시 구현하면서 활용방법을 살펴보겠습니다.

 

로그인

/** 로그인 시도 함수 */
const postSignIn = (req, res, next) => {
  passport.authenticate("local", (authError, user, info) => {
    if (authError) {
      console.error(authError);
      return next(authError);
    }
    if (!user) {
      return res.redirect(`/?loginError=${info.message}`);
    }
    return req.login(user, (loginError) => {
      if (loginError) {
        console.error(loginError);
        return next(loginError);
      }
      console.log("successed");
      return res.redirect("/");
    });
  })(req, res, next);
};

먼저 passport.authenticate("local", ···)를 실행하여 localStrategy.js의 passport.use()를 불러옵니다.

done(null, exUser);
...
done(null, false, { message: "password incorrected." });
...
done(null, false, { message: "no user" });

그 과정에서 3개의 done이 있습니다. 첫 번째 done(null, exUser)는 정상적으로 처리되었을 때의 done입니다. 이 done은 passport.authenricate의 두 번째 매개변수 함수로 넘어가 매칭됩니다.

 

authError, user, info 이렇게 세 개의 매개변수가 존재하는데, done의 인수가 그대로 전해집니다. 앞선 사례에서는 null이 authError로, exUser가 user로, info는 아무 것도 전해지지 않습니다.

 

그 상황에서 로그인 API로 다시 넘어가보면, authError가 null이기 때문에

if (authError) {
	console.error(authError);
    return next(authError);
}

이 부분은 해당되지 않아 다행히 넘어갑니다.

if(!user) {
    return res.redirect(`/?loginError=${info.message}`);
}

이 부분도 user가 존재하기 때문에 넘어갑니다. 그 후로 최종적으로

return req.login(user, loginError) => {
	if (loginError) {
    	console.error(loginError);
        return next(loginError);
    }
    return res.redirect("/");
    });

이 부분을 거쳐서 로그인하게 됩니다. 여기까지 과정을 다시 살펴보면

 

  1. 로그인을 시도하면서 passport.authenticate("local", ... ) 미들웨어를 거칩니다.
  2. authenticate가 호출되며 passport.use(new LocalStrategy())로 넘어가 실행됩니다.
  3. passport.use()를 통해 done을 통해 인자가 다시 passport.authenticate("local", (authError, user, info) => {})의 두 번째 매개변수로 전달됩니다
  4. 각 조건에 따라 처리되며, req.login()이 호출됩니다.

그 다음 req.login으로 인해서 passport.serializeUser()로 넘어가 실행됩니다.

/** 사용자 세션 작성 */
passport.serializeUser((user, done) => {
	done(null, user.id);
});

passport.serializeUser((user, done) => {})에서 user에 로그인 사용자의 정보가 전달된 후에 done(null, user.id); 를 거쳐서 req.session 에 user.id를 저장합니다. 그 후에 바로 passport.deserializeUser()로 넘어갑니다.

/** 사용자 세션 해석 */
passport.deserializeUser((id, done) => {
    User.findOne({ where: { id } })
      .then((user) => done(null, user))
      .catch((err) => done(err));
});

이때, user.id 값이 passport.deserializeUser()의 콜백 함수의 매개변수 id 값으로 전달이 됩니다. 그 후에 쿼리를 거쳐 원하는 값을 찾아내고, 그 찾아낸 값이 done(null, user)를 통해서 req.user에 값이 저장됩니다. 만약 찾지 못한다면 done(err)에서 err 값이 req.login()의 콜백함수 매개변수로 전달되어 로그인 에러를 일으킵니다.

 

req.user에 값이 전달되는 것은, done(null, user)에서

return req.login(user, (loginError) => {
  if (loginError) {
    console.error(loginError);
    return next(loginError);
  }
  console.log("successed");
  return res.redirect("/");
});

loginError가 null이기 때문에 루트 경로로 리다이렉트되며 req.user에 세션 쿠키가 전달되는 것입니다. 정리하자면,

 

  1. serializeUser를 통해서 세션에 값을 저장합니다.
  2. deserializeUser에서 쿼리를 실행해 값이 있다면 req.user에 값이 저장되고 리다이렉트 됩니다.
  3. 쿼리를 실행했는데 값이 없으면 loginError 조건문을 거쳐서 에러를 일으킵니다.

세션 쿠키 유지

세션 쿠키가 계속해서 사이트에서 유지되는 것은, 패스포트 미들웨어를 설정했기 때문입니다. 위에서 passport.session()을 미들웨어로 넣은 것 기억하시나요?

app.use(passport.session());

이 passport.session() 함수가 passport.deserializeUser()를 호출합니다. 이 호출에서 id 값으로 사용자를 조회하고 일치하는 사용자 값이 있다면 요청 객체(req.user)에 값이 부여됩니다.

 

패스포트는 참 편리한데 조금 어려운 것 같습니다. 다음에는 카카오나 구글 등으로 로그인 하는 방법도 구현해보겠습니다.