Next.js 블로그 만들기 - (3) 레포 분리, Webpack, OAuth2, JWT

shorecrab·2022년 6월 19일
5

이번에는 인증 기능을 추가하면서 기존 Next API를 express 서버로 이전하고 레포지토리 분리를 하게 되었다. 쿠키를 만들고 싶은데 Next API에서는 Set-Cookie 헤더를 직접 넣어줘야하는 부분이 마음에 들지 않았고, 생각보다 Next API가 express와 달리 서드파티 라이브러리를 사용하는데 제약이 많아서 분리하기로 결정했다. 분리하고 나서 생각해보니 처음부터 분리했으면 좋았을 것 같다는 생각이 들었다. 미리 분리했다면 multer 대신 formidable 쓰는 일도 없었을텐데 말이다.

레포지토리 분리

레포지토리를 분리하기 위해서 우선 Express 서버를 만들었다.

// blog_server : server/index.ts
import express from 'express';
import posts from './routes/posts';
import auth from './routes/auth';
import cookieParser from 'cookie-parser';

const app = express();
const port = 5000;

app.use('/static', express.static('uploads'));
app.use(cookieParser());

app.use('/posts', posts);
app.use('/auth', auth);

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

5000번 포트로 서버를 열고 API 라우트를 분리했다. 그리고 해당 라우트에 맞게 기존 Next API에서 사용하던 코드를 변형했다. 또한 정적 파일도 전송할 수 있게 express-static을 사용했다. API 로직 자체는 큰 변경이 없어서 여기에 올리지는 않겠다. (그리고 항상 깃헙에 공유되어 있다.)

Webpack 설정

사실 코드를 migrate하는 것은 어렵지 않았는데, 항상 webpack + babel + typescript 설정을 어떻게 하느냐가 문제가 되는 것 같다. 처음에는 typescript만 사용하고 싶었는데 Nodemon 등의 편의 기능을 활용하려면 결국에 webpack을 사용해야 했다.

webpack 설정은 항상 처음부터 하기에는 어려운 것 같아서 boilerplate를 가져온 후에 적당히 변형하고 있다. 그래도 각 옵션이 어떤 것인지는 알아야 하기 때문에 여기에서 설명을 하고자 한다.

// blog_server : webpack.config.js
module.exports = (env, argv) => {
  return {
    entry: {
      server: './server/index.ts',
    },
    output: {
      path: path.join(__dirname, 'dist'),
      publicPath: '/',
      filename: '[name].js',
    },
    resolve: {
      plugins: [new TsconfigPathsPlugin()],
      extensions: ['.ts', '.js'],
    },
    mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
    target: 'node',
    node: {
      __dirname: false,
      __filename: false,
    },
    externals: [nodeExternals()],
    module: {
      rules: [
        {
          test: /\.ts$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env', '@babel/preset-typescript'],
            },
          },
        },
      ],
    },
    plugins: [
      new NodemonPlugin(),
      new Dotenv({
        path: process.env.NODE_ENV === 'production' ? '.prod.env' : '.dev.env',
      }),
    ],
  };
};

entry : 번들링을 시작하는 위치이다. 해당 파일에서부터 dependency graph를 만들어서 분리된 각각의 파일을 하나의 파일로 만든다. 여기에서는 express 서버가 정의된 server/index.ts를 시작점으로 잡아서 번들하고 있다.

output : 번들 결과물이 나올 위치를 지정한다.

resolve : 어떤 파일을 번들링할지 정한다. webpack은 원래 .js 또는 .json으로 끝나는 파일만 번들링하도록 되어있는데, 타입스크립트를 사용하고 있으므로 extensions에 .ts를 명시해줬다.

  • TsconfigPathsPlugin은 타입스크립트의 baseUrl설정에 따라 path를 결정할 수 있도록 한다. baseUrl은 상대경로 대신에 baseUrl에서 명시한 위치에서부터 절대경로를 사용할 수 있도록 해서 상당히 편리하다. 문제는 이 설정이 타입스크립트에서만 유효하고 webpack은 알 수 없다는 점인데, 이것을 webpack이 알 수 있도록 resolve 해주는 플러그인인 TsconfigPathsPlugin 인 것이다. 그래서 이를 적용하면 아래처럼 사용할 수 있다.
// blog_server : server/routes/auth/index.ts
/*
디렉토리 구성
 lib
   ㄴ auth
 server
   ㄴ routes
       ㄴ auth
           ㄴ index.ts
*/

// baseUrl 설정이 없을 경우
import passport from '../../../lib/auth';


// baseUrl: '.' 설정이 있을 경우
import passport from 'lib/auth';

mode : webpack에게 production인지를 명시해서 빌트인 최적화를 할 지 말지 결정한다.

target : 어떤 환경에서 번들 결과물을 사용할 것인지 명시한다. electron일 수도 있고, node일 수도 있고, web일 수도 있다. 각각에 맞춰서 webpack이 번들링한다고 한다.

node : __dirname이나 __filename을 webpack이 변경하도록 할 것인지, 아니면 node.js에서 사용하는 방식 그대로 사용할 것인지를 명시한다. true로 두게 되면 번들되기 전의 위치를 나타낼 것이고, false로 두게 되면 번들된 후의 위치를 나타낸다. 일반적으로 번들 후의 위치를 가져와야하기 때문에 false를 사용했다.
(https://webpack.js.org/configuration/node/#node)

externals : node_modules를 번들링하지 않도록 nodeExternals() 를 사용했다. front-end면 모를까 back-end에서는 모듈 코드를 전송할 필요가 없는데 이를 번들링할 이유가 없다. webpack-node-externals 설명에 있는 링크를 따라가서 읽어봤더니 아래와 같은 글이 있어서 확실히 알게 되었다.

But there is a problem. Webpack will load modules from the node_modules folder and bundle them in. This is fine for frontend code, but backend modules typically aren't prepared for this (i.e. using require in weird ways) or even worse are binary dependencies. We simply don't want to bundle in anything from node_modules.
(https://archive.jlongster.com/Backend-Apps-with-Webpack--Part-I)

module : 엄청나게 많은 설정이 들어갈 수 있는 부분이지만 여기서는 loader를 어떻게 사용하는지만 말하려고 한다. (다른 설정은 사실 잘 사용해본 적이 없어서 모르겠다..)

  • rules : 특정 파일에 대해서 로더를 적용시킬 규칙을 정한다. test로 파일명에 대한 정규표현식을 명시하면, 해당 파일에 대해서 use에 명시된 로더를 적용한다. 주의할 점은 로더 배열을 만들 때 뒤에서부터 적용이 된다는 점이다. 여기서는 babel-loader 하나만 적용하고 있으므로 큰 상관은 없다.
  • babel-loader : 명시된 파일에 babel을 적용한다. 여기서는 preset-typescript를 사용해서 .ts 파일을 컴파일할 수 있도록 했다. 주의할 점은 preset-typescript는 정적 타입 검사를 하지 않고 타입을 다 제거하기 때문에 별도로 빌드 전에 tsc 스크립트를 실행해야 한다는 점이다.

plugins : 말 그대로 적용할 플러그인에 대해서 명시할 수 있다.

  • NodemonPlugin : Nodemon 패키지를 웹팩과 함께 사용할 수 있도록 해주는 플러그인이다. webpack --watch 스크립트를 실행하면 Nodemon이 함께 실행되어서 빠른 재실행을 지원할 수 있다.
  • Dotenv : .env 파일을 적용할 수 있도록 해주는 플러그인이다.

이런 옵션을 명시해주어서 웹팩 설정을 마칠 수 있었다. back-end와 front-end를 monorepo가 아니라 별도의 레포지토리로 관리하면 설정 파일을 쉽게 관리할 수 있다는 점이 좋은 것 같다.

인증

추후에 추가할 댓글 기능을 위해서나, 아니면 admin authorization을 위해서나 인증 기능을 필수적으로 구현해야했다.

웹 서비스를 만들 때 가장 어려운 부분 중에 하나가 인증이 아닐까 싶다. 보안에도 신경써야할 뿐더러 front-end와 back-end가 정말 긴밀하게 연결되어 있기 때문이다.
그래서 나 역시 어떻게 인증을 구현할까 생각해보다가 OAuth2를 통한 인증이 가장 무난할 것 같아서 이것을 사용하기로 했다. 그 중에서도 Github OAuth2를 활용했다.

내가 생각했을 때 OAuth2를 통한 인증이 좋은 점은
1. 민감한 정보를 내가 직접 저장하지 않아도 된다. DB 탈취 시에도 큰 문제를 일으키지 않을 수 있다.
2. 저장해야 될 유저 정보의 양이 줄어든다.
3. 별도의 회원가입이 필요 없다. (사용자의 심리적 장벽이 낮아진다.)
정도가 될 것 같다.

또한 OAuth2의 장점을 최대한으로 활용하기 위해서 (DB 저장 최소화), access_token을 jwt로 전송해서 클라이언트 쪽에서 관리하도록 했다. 이렇게 하면 쿠키 탈취에 취약할 수 있지만, 보안 옵션을 통해서 그 가능성을 최소화하려 한다.

구현


Payco의 OAuth2 인증 과정이 잘 나와있어서 해당 그림을 가져왔다. 전체적 과정은 이 그림을 통해 이해할 수 있을 것이라 생각한다. 진행했던 과정은 아래와 같다.

1. Github OAuth 키를 발급 받고 callback URL을 명시해 준다.


Profile - Settings - Developer Settings - OAuth Apps로 가서 OAuth 키를 발급 받을 수 있다. 키를 발급 받을 때 위처럼 Callback URL을 명시할 수 있는 곳이 있다. Callback URL은 그림에서 6번에 명시된 과정에서 사용자의 브라우저 단에서 redirect되는 URL로, 여기서 요청을 받은 서버가 Access token을 받아내면 된다.

2. passport 설정

passport는 인증을 위한 미들웨어인데, Node 서버에서는 광범위하게 사용되는 패키지이다. 인증 방법에 맞게 Strategy를 선택해서 사용할 수 있는데, 여기서는 passport-oauth2의 strategy를 활용했다.

// blog_server : lib/auth.ts
passport.use(
new OAuth2Strategy(
  {
    authorizationURL: 'https://github.com/login/oauth/authorize',
    tokenURL: 'https://github.com/login/oauth/access_token',
    clientID: process.env.CLIENT_ID as string,
    clientSecret: process.env.CLIENT_SECRET as string,
    callbackURL:
    process.env.NODE_ENV === 'development'
    ? 'http://lvh.me:3000/api/auth/callback'
    : 'https://shorecrabs.site/api/auth/callback',
    scope: 'read:user',
  },
  function (
    accessToken: string,
     refreshToken: string,
     profile: string,
     cb: (err: null | Error, res: any) => void
  ) {
  cb(null, { accessToken, profile });
})
)

Strategy 설정은 위와 같이 진행했다. Github OAuth 같은 경우는 인증 요청을 https://github.com/login/oauth/authroize 로 보내야 한다. 그래서 authorizationURL을 위와 같이 설정했다. Token을 받아오는 URL은 https://github.com/login/oauth/access_token 이어서, tokenURL을 위와 같이 설정했다.

clientID나 clientSecret은 .env 파일에 담아서 빌드 타임에 포함될 수 있도록 했고, callbackURL을 여기에서도 명시했다. scope는 얼마 만큼의 권한을 받을 것인지를 명시하는 옵션인데, 블로그를 위해서는 유저 정보만 가져오면 되기 때문에 최소한의 권한만 요청했다.

마지막으로 콜백 함수를 인자로 받는데, 이 함수는 인증이 성공하면 실행된다. cb(err, res) 함수는 res로 넘겨준 객체를 req.user에 넣어서 API 로직에서 사용할 수 있도록 한다. JWT에 토큰을 넣어야 하기 때문에 여기서는 access_token을 그대로 반환하도록 했다.

jwt를 생성하는데서도 passport-jwt를 활용했다.

// blog_server : lib/auth.ts
  .use(
    new JwtStrategy(
      {
        jwtFromRequest: cookieExtractor,
        secretOrKey: process.env.JWT_PUBLIC_KEY,
        algorithms: ['ES256'],
      },
      function (jwt_payload, done) {
        if (jwt_payload) done(null, jwt_payload);
        else done(Error('no payload'), null);
      }
    )

JWT 설정에 따라 payload에 대해서 암호화를 할 수 있다. 여기서는 ECDSA 암호화 방식을 사용했다. 비대칭 암호화 방식이라서 공개 키를 설정에 넣어주고, JWT를 보낼 때는 비밀 키로 암호화한다. JWT 검증 시에는 공개 키로 복호화해서 서버가 보낸 JWT가 맞는지 확인할 수 있다.

3. 인증 요청을 보내는 API 생성

실제 인증을 보내기 위한 API를 생성해야한다. /auth에 GET 요청을 보내면 인증을 진행하도록 했고, /auth/callback에서 access_token을 받아오도록 했다.OAuth는 별도의 로직 없이 passport를 통해서 인증 요청을 보낼 수 있어서 편리하게 진행할 수 있었다.

JWT를 보내는 부분을 보면 jwt.sign() 함수가 눈에 띈다. 아까 말한 암호화를 하는 부분이 여기서 진행되는 것이다.

또한 JWT 쿠키를 보낼 때 보안 옵션들이 사용되었다. JWT는 쿠키가 한번 탈취되면 서버에서 invalidation 하기 어렵다는 단점이 있기 때문에, 쿠키가 탈취되지 않도록 하는 것이 중요하기 때문이다.

sameSite=strict 옵션은 쿠키가 samesite일 때만 보내지도록 하는 옵션이다. 즉, eTLD+1가 같을 때만 쿠키가 보내지게 된다. CSRF 공격을 막는데 유용하다.
(첨언 : samesite different origin의 XSS를 활용한 CSRF에는 취약하지만 같은 eTLD+1을 사용하는 사이트가 없으므로 별도의 CSRF 방지는 하지 않을 것이다.)

secure 옵션은 쿠키가 https를 사용할 때만 보내지도록 하는 옵션이다. MITM 공격을 통한 쿠키 탈취를 막는데 유용하다.

httpOnly 옵션은 자바스크립트를 통해서 쿠키에 접근하지 못하도록 하는 옵션이다. XSS 공격을 통한 쿠키 탈취를 막는데 유용하다.

router
  .use(passport.initialize())
  .get('/', passport.authenticate('oauth2'), async (req, res, next) => {
    res.end();
  })
  .get(
    '/callback',
    passport.authenticate('oauth2', {
      failureRedirect: '/',
      session: false,
    }),
    async (req, res, next) => {
      try {
        const { accessToken } = req.user as any;

        const result = await axios.get('https://api.github.com/user', {
          headers: {
            Authorization: `token ${accessToken}`,
          },
        });

        const payload = jwt.sign(
          {
            accessToken,
            authLevel:
              result.data.login === process.env.ADMIN_ID ? 'admin' : 'user',
          },
          process.env.JWT_PRIVATE_KEY as string,
          { algorithm: 'ES256' }
        );

        res
          .cookie('jwt', payload, {
            maxAge: 7200000,
            sameSite: 'strict',
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
          })
          .redirect('/');
      } catch (err) {
        console.error(err);
        res.redirect('/login');
      }
    }
  )

위 과정을 모두 거치면 브라우저에 JWT 쿠키를 생성하고, 이를 통한 인증을 할 수 있게 된다.

마무리

이번에는 monorepo의 분리와 함께 webpack 설정을 하고, OAuth와 JWT를 활용한 인증을 구현했다. 인증은 특히 어려운 부분이지만, 간단하지만 안전한 방식을 찾아서 구현할 수 있었다. 이후에 이를 바탕으로 댓글 기능 등을 만들면 좋을 것 같다.

다음에는 배포를 해보고 메인 페이지에서 블로그 포스트에 대한 페이지네이션을 추가해야할 것 같다. 또한 인증 기능이 추가되었으니 포스트 수정, 삭제 기능도 만들 수 있을 것이다.

profile
주니어 프론트엔드 개발자!

1개의 댓글

comment-user-thumbnail
2023년 3월 27일

잘 보고 갑니다.

답글 달기