Refresh Token 및 Mocks Server 적용

2022년 1월 10일
Template Library

Template Library 시리즈의 네 번째 글은 Template Library에 Refresh Token을 적용한 이야기입니다.

Template Library에는 Real World Backend Specs에 맞추어 JWT가 적용되어 있었습니다. 이번 미들웨어 관련 정리를 하면서 미들웨어를 적용할 수단으로 인증을 생각했고, 인증을 적용하는 김에 관심이 있던 Refresh Token에 대한 처리도 같이 추가하고자 하였습니다.

Nest.js

Nest.js 공식 문서상에는 JWT에 대한 적용만 있을 뿐, Refresh Token에 대한 상세한 내용이 없어 다른 글들을 찾아보았습니다. 그 중 Leo님이 작성하신 NestJS Auth Refresh Token이라는 글이 전반적인 느낌을 알기가 쉬워 이를 참고삼아, Template Library에 맞추어 녹여내었습니다. 작성할 때는 테스트를 짜고 확인하느라 시간이 오래 걸렸지만, 막상 정리하려고 보니 많은 내용은 없어서 간단히 정리하고 넘어가고자 합니다.

우선 기존의 JWT 적용할 때 사용한 JwtStrategy와 유사하게 JwtRefreshStrategy를 만들고, 이 Strategy에서 Refresh Token을 통해 User를 가져오게 됩니다. (필자는 Refresh Token을 도입할 것을 생각하고 User에 작성할 때 미리 Refresh Token을 넣을 수 있게 작업해두었습니다.)

export class JwtRefreshStrategy extends PassportStrategy(Strategy, JWT_REFRESH_NAME) {
  constructor(private readonly userService: UserService, private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([request => request?.body?.refreshToken]),
      secretOrKey: configService.get<string>('jwt.refresh-token.secret'),
      passReqToCallback: true,
    });
  }

  async validate(request: Request, payload: IJwtPayload): Promise<UserDto> {
    const refreshToken = request?.body?.refreshToken;
    const user = await this.userService.getUserByRefreshToken(payload.email, refreshToken);

    if (!user) {
      throw new UnauthorizedException('Not found user');
    }

    user.refreshToken = refreshToken;

    return user;
  }
}

JWT와 동일하게 앞서 작성한 JwtRefreshStrategy을 사용하는 가드를 작성합니다.

export class JwtRefreshGuard extends AuthGuard(JWT_REFRESH_NAME) {}

AuthController에서 가드를 사용하여 Refresh Token이 유효한지 확인하고, 유효하다면 새로운 Access Token을 발급하여 줍니다.

export class AuthController {
  @NoAuth()
  @UseGuards(JwtRefreshGuard)
  @Post('/refresh')
  @ApiOperation({ summary: 'refresh' })
  @ApiBody({ description: 'refresh body', type: RefreshDto })
  @ApiResponse({ status: 200 })
  @ApiResponse({ status: 400, description: 'Unauthorized' })
  async refresh(@CurrentUser() currentUser: UserDto): Promise<UserDto> {
    return this.authService.refresh(currentUser);
  }
}

AuthService에서는 다음과 같이 새로운 토큰을 발급하는 것을 확인하실 수 있습니다.

export class AuthService {
  async refresh(user: UserDto): Promise<UserDto> {
    const payload = {
      email: user.email,
      username: user.username,
    } as IJwtPayload;

    user.token = this.jwtService.sign(payload, {
      secret: this.configService.get<string>('jwt.access-token.secret'),
      expiresIn: `${this.configService.get('jwt.access-token.expiration-time')}s`,
    });

    return user;
  }
}

Next.js

이번 작업을 하는 동안에 가장 많은 고민한 부분은 Next.js에서 토큰을 어떻게 처리할지에 대한 부분이었습니다. 아무래도 CSR과 SSR이 함께하다 보니 최초에 토큰을 받아오는 부분부터 토큰이 갱신되고, 소멸하는 부분까지의 처리가 고민되었습니다. 그래서 다음과 같은 기준으로 작업을 진행하고자 하였습니다.

  1. 인증에 대한 토큰은 Cookie로 관리하며, 사용자의 정보는 App.getInitialProps에서 호출한다.
  2. 최초 토큰 세팅은 CSR에서 진행한다. (/api/auth/login)
  3. 토큰에 대한 갱신은 CSR, SSR 모두에서 진행된다.(API 사용 중 토큰이 유효하지 않을 경우, /api/auth/refresh 호출)
  4. 토큰에 대한 소멸은 CSR, SSR 모두에서 진행된다.
    • CSR에서 명시적으로 삭제한다. (User에서 Refresh Token도 삭제, /api/logout 호출)
    • CSR, SSR에 Cookie를 삭제한다. (Refresh Token도 유효하지 않을 경우, AccessToken, Refresh Token 모두 Cookie에서 삭제)

인증의 쿠키를 좀 더 명시적으로 관리하기 위해서, 기존에 프록시를 사용하여 서버에서 쿠키를 설정하던 부분을 제거하고 클라이언트에서 설정하도록 변경하였습니다. 그리고 CSR과 SSR에서 동일한 형식으로 쿠키를 처리하기 위해서 next-cookie를 사용하였습니다. 아쉽게도 해당 라이브러리는 Middleware에 대한 쿠키 관리가 없어서 이를 위해 별도로 상속받아 사용하게 되었습니다. 추가로 App.getInitialProps에서 설정된 쿠키를 Page.getServerSideProps에서도 확인하기 위해 이를 확장하였습니다(App.getInitialProps에서 토큰이 갱신되거나 별도의 작업이 진행되었을 경우를 위해).

이때 빌드된 next-cookie의 내부에서 eval을 Middleware에서 사용이 불가능하여, 사용하고 있는 Cookie만 별도로 가져와 사용하도록 구성하였습니다.

export type NextContext =
  | NextPageContext
  | GetServerSidePropsContext
  | { req: NextApiRequest; res: NextApiResponse }
  | string;
export type NextMiddlewareContext = { req: NextRequest; res: NextResponse };

export default class SuperCookie extends Cookie {
  private readonly mCtx?: NextMiddlewareContext;

  constructor(context?: NextContext, nextMiddlewareContext?: NextMiddlewareContext) {
    super(context);
    this.mCtx = nextMiddlewareContext;

    if (this.mCtx) {
      this.cookie = new universalCookie(this.mCtx?.req.cookies);
    }

    if (this.ctx) {
      parse((this.ctx.res?.getHeader('set-cookie') as string | string[]) ?? '', { map: false }).forEach(
        ({ name, value, ...options }) => {
          this.set(name, value, options as CookieSetOptions);
        },
      );
    }
  }

  public set(
    name: string,
    value:
      | {
          [key: string]: unknown;
        }
      | string,
    options?: CookieSetOptions,
  ): void {
    if (this.isServer && this.mCtx) {
      this.cookie.set(name, value, options);
      this.mCtx.res.cookie(name, value, options);
    } else {
      super.set(name, value, options);
    }
  }

  public remove(name: string, options?: CookieSetOptions): void {
    if (!this.has(name)) {
      return;
    }

    if (this.isServer && this.mCtx) {
      const opt = Object.assign(
        {
          expires: new Date(),
          path: '/',
        },
        options || {},
      );
      this.mCtx.res.cookie(name, '', opt);
    } else {
      super.remove(name, options);
    }
  }
}

시리즈의 이전 글인 Next.js 12 업데이트를 참고하여, 가장 처음 접근되는 Root Middleware에서 별도의 호출 없이 토큰의 유효성만 확인하는 /api/validate를 호출하여 토큰을 갱신하고자 하였습니다. 하지만 아쉽게도 이 방식은 예상과 다른 Middleware의 동작에 포기하고 제거하였습니다.

App.getInitialProps에서 설정한 갱신된 쿠키 등의 값을 Page.getServerSideProps의 Response header에서 찾아서 사용하였듯, 각 Middleware의 계층 간에 쿠키를 공유하리라 생각하였지만, 실제로는 한 사이클의 호출동안 각 Middleware는 앞선 Middleware가 설정한 값을 보지 못하였습니다. 가장 마지막에 설정된 값 또한 Root Middleware에 다른 Resource들이 불리며 덮어 씌이게 되어 의도한 바와 다르게 동작하였습니다.

마지막으로 Middleware에서 예상 밖에 겪은 이슈는 Middleware에서 axios를 사용할 수 없다는 점이었습니다. axios 내부의 노드 모듈을 사용하는 부분 등의 이슈로 동작하지 않는 것으로 보이나, Next.js 팀은 fetch가 표준이므로 이를 사용할 것을 권장하고, 이 문제가 해결된 새로운 버전의 axios은 계속 진행 중이어 Next.js에서 사용하던 axios를 거두어내고 fetch로 변경하였습니다.(관련 1, 관련 2)

SuperCookie를 가진 별도의 Context를 만들어 API에 전달하고 이 Context에서 쿠키를 가져와 사용하는 방식으로 앞서 작성한 기준을 충족하도록 구현하였습니다. CSR은 최초 로드 시에 Context를 초기화하고 SSR에서는 호출이 발생할 때마다 Context를 생성하도록 작성하였습니다.

export interface ContextOptions {
  nextContext?: NextContext;
  nextMiddlewareContext?: NextMiddlewareContext;
}

export default class Context {
  private readonly _cookie: SuperCookie;

  public get cookie(): Cookie {
    return this._cookie;
  }

  constructor(options: ContextOptions = {}) {
    this._cookie = new SuperCookie(options.nextContext, options.nextMiddlewareContext);
  }
}

// _middleware
export async function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const context = new Context({
    nextMiddlewareContext: {
      req: request,
      res: response,
    },
  });

  try {
    await AuthAPI.validate(context);
  } catch (e) {}

  const accessToken = getCookie(context, CookieName.ACCESS_TOKEN);

  if (accessToken) {
    return NextResponse.redirect(`/`, 308);
  }

  return NextResponse.next();
}

// App.getInitialProps
MyApp.getInitialProps = async (appContext: AppContext): Promise<AppInitialProps> => {
  const appProps = await App.getInitialProps(appContext);
  const { ctx } = appContext;

  let user = null;

  try {
    const context = new Context({ nextContext });
    user = await UserAPI.get(context);
  } catch (e) {}

  return {
    ...appProps,
    pageProps: {
      pathname: ctx.pathname,
      user,
    },
  };
};

// Page.getServerSideProps
export async function getServerSideProps(ctx: NextPageContext): Promise<GetServerSidePropsResult<PropsType>> {
  const slug = (ctx.query.slug as string) ?? null;
  const context = new Context({
    nextContext: ctx,
  });

  let article: IArticle;

  try {
    article = await ArticleAPI.get(context, slug);
  } catch (e) {}

  if (!article) {
    return {
      redirect: {
        destination: '/404',
        permanent: false,
      },
    };
  }

  return {
    props: {
      slug,
      article,
    },
  };
}

// API
export default class ArticleAPI {
  private static BASE_URL = `${API_SERVER_URL}/api/articles`;

  static async create(context: Context, request: CreateArticleRequest): Promise<IArticle> {
    return BaseAPI.post<CreateArticleRequest, IArticle>(context, `${ArticleAPI.BASE_URL}`, request);
  }
  static async update(context: Context, slug: string, request: UpdateArticleRequest): Promise<IArticle> {
    return BaseAPI.put<UpdateArticleRequest, IArticle>(context, `${ArticleAPI.BASE_URL}/${slug}`, request);
  }
}

Mocks Server

이번에 작업하면서 추가되고 깨진 테스트들에 대해 정리하였습니다. 기존에 Cypress에서 SSR의 요청을 처리하기 위해 적용되어 있던 mockhttp 기반의 코드를 Mocks Server로 모두 이관하였습니다. 이로 이관함으로써 얻어지는 이점은 좀 더 명확한 Mock 데이터를 처리할 수 있으며, 무엇보다 코드 작성 및 유지보수에 좋다는 생각이 들었습니다. 우선 기존에 작성된 코드를 보자면 다음과 같습니다.

Mock 서버 구동을 위한 코드입니다.

const { getStandalone, getLocal } = require('mockttp');

const mockServerManager = getStandalone();
const mockServer = mockServerManager.start();

const local = getLocal();
local.start(9999);
local.get('/').thenReply(200, 'Mock server is up');

mockServer.then(() => {
  console.log('Mock server manager is started');
});

// Probably not necessary
process.on('SIGINT', function () {
  mockServerManager.stop().then(() => {
    console.info('\nMock server manager was gracefully shut down');
    process.exit();
  });
});

Mock 서버와 통신하기 위한 boilerplate를 제거하기 위한 Command 코드입니다.

import 'cypress-file-upload';
import { getRemote } from 'mockttp';

const mockServer = getRemote();

Cypress.Commands.add('mockServerStart', port => {
  return mockServerStart(port);
});

Cypress.Commands.add('mockServerStop', () => {
  return mockServerEnd();
});

Cypress.Commands.add('mockServerBuilder', (method, url, response, statusCode) => {
  return mockServerBuilder(method, url, response, statusCode);
});

Cypress.Commands.add('login', (fixture = 'user/user.json') => {
  cy.fixture(fixture).then(user => {
    const response = {
      statusCode: 200,
      body: user,
    };
    cy.setCookie('RW_AT', user.token);
    if (user.refreshToken) {
      cy.setCookie('RW_RT', user.refreshToken);
    }
    cy.intercept('POST', '/api/auth/login', req => {
      const date = new Date();
      date.setDate(date.getDate() + 7);

      req.continue(res => {
        res.headers = {};
        res.headers['Set-Cookie'] = [`RW_AT=${user.token}; Path=/; Expires=${date.toUTCString()}; HttpOnly`];
        res.send(response);
      });
    }).as('login');
    cy.intercept('GET', 'http://localhost:8080/api/users', req => {
      req.continue(res => {
        res.send(response);
      });
    }).as('getCurrentUser');

    // https://stackoverflow.com/questions/47631821/mocking-server-for-ssr-react-app-e2e-tests-with-cypress-io
    mockServerBuilder('get', '/api/users', response.body, response.statusCode);
  });
});

Cypress.Commands.add('prepareArticle', (delay = 0, fixture = 'article/article.json') => {
  cy.fixture(fixture).then(article => {
    cy.intercept('GET', `http://localhost:8080/api/articles/${article.slug}`, {
      delay,
      fixture,
    }).as('getArticle');
    cy.intercept('GET', `http://localhost:8080/api/articles/${article.slug}/comments?limit=999`, {
      delay,
      fixture: 'article/comments.json',
    }).as('geComments');
  });
});

async function mockServerStart(port = 8080) {
  try {
    return await mockServer.start(port);
  } catch (e) {
    mockServer.mockServerConfig = { port: 8080 };
    await mockServer.requestFromMockServer('/stop', {
      method: 'POST',
    });
    mockServer.mockServerConfig = null;
    return mockServer.start(port);
  }
}

function mockServerEnd() {
  return mockServer.stop();
}

function mockServerBuilder(method, url, response, statusCode = 200) {
  return mockServer[method](url).thenReply(statusCode, JSON.stringify(response));
}

실제 테스트를 위한 코드입니다.

import format from 'date-fns/format';
describe('Article', () => {
  let article = null;
  let comments = null;
  beforeEach(() => {
    cy.fixture('article/article.json').then(a => {
      article = a;
    });
    cy.fixture('article/comments.json').then(c => {
      comments = c;
    });
  });

  it('should be show content', () => {
    cy.prepareArticle();
    cy.visit(`http://localhost:3000/article/${article.slug}`);
    cy.wait(['@getArticle', '@geComments']);
    cy.get('[data-cy=head-title]').contains('ARTICLE');
    cy.get('[data-cy=article-banner-author-image] > div > span > img').should('have.attr', 'srcset').and('contain', encodeURIComponent(article.author.image));
    cy.get('[data-cy=article-banner-author-username] > a').should(
      'have.attr',
      'href',
      `/profile/${article.author.username}`,
    );
    cy.get('[data-cy=article-banner-author-date]').contains(format(new Date(article.updatedAt), 'EEE MMM d yyyy'));
    cy.get('[data-cy=article-banner-author-date]').contains(format(new Date(article.updatedAt), 'EEE MMM d yyyy'));
    cy.get('[data-cy=article-content-body]').contains('It takes a Jacobian');
    cy.get('[data-cy=article-content-tags]').should('have.length', 2);
    cy.get('[data-cy=article-content-author-image] > div > span > img').should('have.attr', 'srcset').and('contain', encodeURIComponent(article.author.image));
    cy.get('[data-cy=article-content-author-username] > a').should(
      'have.attr',
      'href',
      `/profile/${article.author.username}`,
    );
    cy.get('[data-cy=comment-container]').should('have.length', 2);
    cy.get('[data-cy=comment-container]').eq(0).contains('It takes a Jacobian');
    cy.get('[data-cy=comment-form-container]').should('not.exist');
  });

  it('should toggle follow', () => {
    cy.prepareArticle();
    cy.mockServerStart(8080);
    cy.login('user/other-user.json');
    cy.visit(`http://localhost:3000/article/${article.slug}`);
    cy.wait('@getArticle');
    cy.intercept('POST', `http://localhost:8080/api/profiles/${article.author.username}/follow`, {
      fixture: 'article/article.json',
    }).as('bannerFollow');
    cy.get('[data-cy=article-banner-follow]').click();
    cy.wait('@bannerFollow');
    cy.intercept('POST', `http://localhost:8080/api/profiles/${article.author.username}/follow`, {
      fixture: 'article/article.json',
    }).as('contentFollow');
    cy.get('[data-cy=article-content-follow]').click();
    cy.wait('@contentFollow');
    cy.mockServerStop();
  });
});

전반적으로 boilerplate를 Command로 처리하여 작업하였지만, Cypress와 Mock 서버의 로직이 한데 엉키어 전반적으로 유지보수에 어려움이 있었습니다. 반면 Mocks Server로 전환하고 많은 로직에서 Cypress와 Mocks Server를 분리하여 작업이 가능하여 깔끔하고 유지보수도 한결 수월했습니다. 또한 별도의 Mock 서버로 구동도 가능하여, 서버를 별도로 켜지 않아도 작업이 가능해서 편리하게 사용하였습니다.

Mock 설정입니다. 필요에 따라 다른 Mock으로 전환이 가능합니다. 필자는 하나에 몰아서 작업하였지만, 필요에 따라 기능별로 별도의 Mock으로 분리하여 사용이 가능하고 상속하는 등 다양하게 사용할 수 있습니다.

module.exports = [
  {
    id: 'base',
    routesVariants: [
      'authorization:enabled',
      'get-articles-tags:success',
      'get-articles:success',
      'get-articles-feed:success',
      'post-articles:success',
      'get-article:success',
      'put-article:success',
      'delete-article:success',
      'get-articles-comments:success',
      'post-articles-comments:success',
      'delete-articles-comment:success',
      'post-articles-favorite:success',
      'delete-articles-favorite:success',
    ],
  },
];

Route에 대한 코드입니다. 친숙한 express 기준으로 requestresponse가 들어오고 이를 사용하여 처리할 수 있습니다. 작성하는 정도에 따라 어느 정도 동작하는 Mock에 대한 작성도 수월합니다. 필요하다면 Real 서버로 프록시도 가능하여, 다양하게 사용할 수 있습니다.

const articles = require('../data/articles');
const comments = require('../data/comments');
const feed = require('../data/feed');

module.exports = [
  {
    id: 'get-articles-tags',
    url: '/api/articles/tags',
    method: 'GET',
    variants: [
      {
        id: 'success',
        response: {
          status: 200,
          body: articles.reduce((tags, article) => {
            const newTags = [...tags];

            article.tagList.forEach(tag => {
              if (tags.indexOf(tag) > -1) {
                return;
              }
              newTags.push(tag);
            });

            return newTags;
          }, []),
        },
      },
    ],
  },
  {
    id: 'get-articles',
    url: '/api/articles',
    method: 'GET',
    variants: [
      {
        id: 'success',
        response: (req, res) => {
          const { page = 1, limit = 20, tag, author, favorited } = req.query;
          let list = articles;

          list = list.filter(article => {
            let valid = true;

            if (tag) {
              valid = article.tagList.indexOf(tag) > -1;
            }

            if (author) {
              valid = article.author.username === author;
            }

            if (favorited) {
              valid = article.author.username === favorited;
            }

            return valid;
          });

          res.status(200);
          res.send({
            count: list.length,
            list: list.slice((page - 1) * limit, page * limit),
          });
        },
      },
    ],
  },
];

Cypress의 Command 코드입니다. boilerplate가 많이 줄어 login에 대한 처리만 Command로 사용하였습니다.

import 'cypress-file-upload';
import '@mocks-server/cypress-commands';

Cypress.Commands.add('login', (email = 'jake@jake.jake') => {
  cy.request('POST', 'http://localhost:8080/api/auth/login', { email }).as('login');
  cy.get('@login').then(response => {
    cy.setCookie('RW_AT', response.body.token);
    cy.setCookie('RW_RT', response.body.refreshToken);
  });
});

Cypress에 대한 코드를 제외하고 제거되었습니다. 필요하다면 Mocks Server에서 제공하는 Cypress의 Command를 사용하여 간단하게 Mock을 변경하는 등의 방식으로 사용할 수 있습니다.

import format from 'date-fns/format';

describe('Article', () => {
  let article = null;
  let otherArticle = null;
  let comment = null;
  beforeEach(() => {
    cy.fixture('article.json').then(a => {
      article = a;
      otherArticle = {
        slug: 'how-to-train-your-dragon-3',
        title: 'How to train your dragon 3',
        description: 'So toothless',
        body: 'It a dragon',
        tagList: ['training'],
        createdAt: '2016-02-18T03:22:56.637Z',
        updatedAt: '2016-02-18T03:48:35.824Z',
        favorited: true,
        favoritesCount: 3,
        author: {
          username: 'Jacob',
          bio: 'I work at statefarm',
          image: 'https://i.stack.imgur.com/xHWG8.jpg',
          following: true,
        },
      };
    });
    cy.fixture('comment.json').then(c => {
      comment = c;
    });
  });

  it('should be show content', () => {
    cy.visit(`http://localhost:3000/article/${article.slug}`);
    cy.get('[data-cy=head-title]').contains('ARTICLE');
    cy.get('[data-cy=article-banner-author-image] > div > span > img')
      .should('have.attr', 'srcset')
      .and('contain', encodeURIComponent(article.author.image));
    cy.get('[data-cy=article-banner-author-username] > a').should(
      'have.attr',
      'href',
      `/profile/${article.author.username}`,
    );
    cy.get('[data-cy=article-banner-author-date]').contains(format(new Date(article.updatedAt), 'EEE MMM d yyyy'));
    cy.get('[data-cy=article-banner-author-date]').contains(format(new Date(article.updatedAt), 'EEE MMM d yyyy'));
    cy.get('[data-cy=article-content-body]').contains('It takes a Jacobian');
    cy.get('[data-cy=article-content-tags]').should('have.length', 2);
    cy.get('[data-cy=article-content-author-image] > div > span > img')
      .should('have.attr', 'srcset')
      .and('contain', encodeURIComponent(article.author.image));
    cy.get('[data-cy=article-content-author-username] > a').should(
      'have.attr',
      'href',
      `/profile/${article.author.username}`,
    );
    cy.get('[data-cy=comment-container]').should('have.length', 2);
    cy.get('[data-cy=comment-container]').eq(0).contains('It takes a Jacobian');
    cy.get('[data-cy=comment-form-container]').should('not.exist');
  });

  it('should toggle follow', () => {
    cy.login('jake-other@jake.jake');
    cy.visit(`http://localhost:3000/article/${article.slug}`);
    cy.intercept('POST', `http://localhost:8080/api/profiles/${article.author.username}/follow`).as('bannerFollow');
    cy.get('[data-cy=article-banner-follow]').click();
    cy.wait('@bannerFollow');
    cy.intercept('POST', `http://localhost:8080/api/profiles/${article.author.username}/follow`).as('contentFollow');
    cy.get('[data-cy=article-content-follow]').click();
    cy.wait('@contentFollow');
  });

});

마치며

언젠가 한 번 추가해야 한다고 생각했던 기능에 대해 추가하며, 많은 부분에 대해서 알아갈 수 있었습니다. 특히 Middleware에 대해서 한층 더 알아간 기회였던 것 같아 기쁘게 작업할 수 있었습니다.

Recently posts
© 2016-2023 smilecat.dev