블로그 이미지
윤영식
Full Stacker, Application Architecter, KnowHow Dispenser and Bike Rider

Publication

07-02 09:44

Category

2021. 10. 7. 10:59 React/Architecture

운영환경을 만들경우 번들링 파일간의 충돌을 최소화하기 위해 i18n 파일의 위치를 변경한다. 

  • Backend i18n은 public에 있을 필요가 없다. 
  • Frontend i18n은 위치도 간소화 한다. 

 

i18N 메세지 파일 위치 변경

Backend i18n 변경

apps/gateway/api/src/public/assets/i18n 의 assets 폴더를 apps/gateway/api/src 폴더 밑으로 위치 변경하고, assets/i18n/api 폴더를 assets/i18n 폴더 밑으로 이동한다. 

apps/gateway/api/project.json 에 assets 경로 추가하여 번들링시 포함되도록 한다. 

apps/gateway/api/src/environments/config.json 에서 i18n 위치를 변경한다.

Dashboard, Configuration, Back-Office의 API Backend에도 동일 환경을 적용한다. 특히 app.module.ts에 내역중 i18n고 TypeORM, Exception Filter 내역을 추가한다. 

import { join } from 'path';
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';

import { GatewayApiAppService, EntitiesModule, AuthModule, AuthMiddleware } from '@rnm/domain';
import { GlobalExceptionFilter, ormConfigService, RolesGuard, TranslaterModule } from '@rnm/shared';

import { environment } from '../environments/environment';
import { DashboardModule } from './dashboard/microservice/dashboard.module';
import { ConfigurationModule } from './configuration/microservice/configuration.module';
import { BackOfficeModule } from './back-office/microservice/back-office.module';
import { AppController } from './app.controller';
import { AuthController } from './auth/auth.controller';
import { UserController } from './user/user.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/auth*',
        '/api/gateway*', '/api/dashboard*', '/api/configuration*', '/api/back-office*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    // ORM
    TypeOrmModule.forRoot({
      ...ormConfigService.getTypeOrmConfig(),
      entities: getMetadataArgsStorage().tables.map(tbl => tbl.target)
    }),
    // i18n
    TranslaterModule,
    // TypeORM
    EntitiesModule,
    // MicroService
    DashboardModule,
    ConfigurationModule,
    BackOfficeModule,
    // Auth
    AuthModule
  ],
  controllers: [
    AuthController,
    AppController,
    UserController
  ],
  providers: [
    GatewayApiAppService,
    // Global Exception Filter
    {
      provide: APP_FILTER,
      useClass: GlobalExceptionFilter,
    },
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ]
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    if(!environment || !environment.production) {
      return;
    }

    consumer
      .apply(AuthMiddleware)
      .forRoutes(...[
        { path: '/dashboard*', method: RequestMethod.ALL },
        { path: '/configuration*', method: RequestMethod.ALL },
        { path: '/back-office*', method: RequestMethod.ALL },
        // { path: '/api/*', method: RequestMethod.ALL },
      ]);
  }
}

 

 

Frontend i18n 위치 변경

apps/gateway/web/src/assets/i18n/web/locale-en.json 파일의 위치를 apps/gateway/web/src/assets/i18n/locale-en.json 로 옮긴다. 

apps/gateway/web/src/environments/config.json 파일을 위의 그림처럼 추가하고, i18n, auth 관련 설정을 넣는다. I18N_JSON_PATH 앞에 /dashboard 가 추가된것에 주의 한다. 

// config.json
{
  "AUTH": {
    "SECRET": "iot_secret_auth",
    "EXPIRED_ON": "1d",
    "REFRESH_SECRET": "iot_secret_refresh",
    "REFRESH_EXPIRED_ON": "7d"
  },
  "I18N_LANG": "en",
  "I18N_JSON_PATH": "/dashboard/assets/i18n/"
}

apps/gateway/web/src/environments/environment.ts 파일에 config.json을 import하여 export 한다.

export const environment = {
  production: false,
};

export const config = require('./config.json');

apps/gateway/web/src/app/core/i18n.ts 파일을 libs/ui/src/lib/i18n 폴더 밑으로 옮기고, 내역을 수정한다. 

import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import XHR from 'i18next-xhr-backend';

export function initI18N(config: any) {
  const backendOptions = {
    loadPath: (config.I18N_JSON_PATH || '/assets/i18n/') + 'locale-{{lng}}.json',
    crossDomain: true,
  };
  
  i18next
    .use(XHR)
    .use(initReactI18next)
    .init({
      backend: backendOptions,
      debug: true,
      lng: config.I18N_LANG || 'en',
      fallbackLng: false,
      react: {
        useSuspense: true
      }
    });
}

libs/ui/src/index.ts export를 추가한다. 

export * from './lib/ajax/http.service';
export * from './lib/i18n/i18n';

 

다음으로 apps/gateway/web/src/main.tsx 에서 initI18N을 초기화 한다. 

import * as ReactDOM from 'react-dom';

import { initI18N } from '@rnm/ui';

import App from './app/app';
import { config } from './environments/environment';

initI18N(config);
ReactDOM.render(<App />, document.getElementById('root'));

 

 

개발환경에서 Dashboard Web Dev Server로 연결하기

 

Gateway - Dashboard 로컬 개발시에는 총 4개의 프로세스가 구동되고 상호 연관성을 갖는다. 

  • Gateway API (NodeJS & NestJS), Gateway Frontend (Web Dev Server & React) 로 Gateway하나에 두개의 프로세스가 구동된다. 
  • Dashboard API, Dashboard Frontend 도 두개의 프로세스가 구동된다. 

4개 프로세스간의 관계

개발시에 전체 루틴을 처리하고 싶다면 위와 같은 Proxy 설정이 되어야 한다. 환경 설정을 다음 순서로 진행한다. 

 

Step-1) Gateway Web에서 Gateway API로 Proxy 

apps/gateway/web/proxy.conf.json 환경은 Dashboard, Configuration, Back-Office 모두를 proxy 한다. 그리고 apps/gateway/web/project.json 안에 proxy.conf.json과 포트 9000 을 설정한다. 

 

Step-2) Gateway API에서 Dashboard Web으로 Proxy

apps/gateway/api/src/environments/config.json 에서 REVERSE_ADDRESS가 기존 Dashboard API 의 8001 이 아니라, Dashboard Web의 9001 로 포트를 변경하면 된다. 

 

Step-3) Dashboard Web 에서 Dashboard API로 proxy

Dashboard API로 proxy 하기위해 apps/dashboard/web/proxy.conf.json 파일을 추가한다. api 호출은 dashboard api의 8001로 proxy 한다.

{
  "/dashboard/api/*": {
    "target": "http://localhost:8001",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  }
}

apps/dashboard/web/project.json 에 설정한다. 

  • proxyConfig
  • port
  • baseHref: "/dashboard/"를 설정한다. "/dashboard"로 하면 안된다.

 

Step-4) Dashboard API 변경사항

apps/dashboard/api/src/public/dashboard 하위 내역을 모드 apps/dashboard/api/src/public으로 옮기고, dashboard  폴더를 삭제한다. 

apps/dashboard/api/src/environments/config.json 의 HTTP 포트는 8001 이다. 

 

테스트 

먼저 콘솔에서 gateway, dashboard web을 구동한다. 

$> nx serve gateway-web
NX  Web Development Server is listening at http://localhost:9000/

$> nx serve dashboard-web
>  NX  Web Development Server is listening at http://localhost:9001/

VSCode에서 gateway, dashboard api를 구동한다. 

브라우져에서 http://localhost:9000 을 호출하고, 로그인 해서 dashboard web 의 index.html 이 호출되는지 체크한다. 

proxy통해 dashboard web의 index.html 호출 성공

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-11

 

Release ms-11 · ysyun/rnm-stack

fixed i18n config and test env

github.com

 

posted by 윤영식
2021. 10. 2. 21:10 React/Architecture

NestJS과 React에 i18n을 적용하고, config 파일로딩에 대한 리팩토링과 기타 기능들을 추가로 적용한다. 

 

 

NestJS에 i18n 적용

nestjs-i18n 패키지를 사용한다. 

$> yarn add nestjs-i18n

 

i18n message 파일은 json 포멧이고, 이를 위해 apps/gateway/api/src/public/assets/i18n/api 폴더를 생성한다. i18n/api 폴더에는 언어에 맞는 폴더를 생성한다. 

  • nestjs 번들링 배포시 api 서버의 i18n 파일은 public/assets/i18n/api 폴더 하위에 위치한다. 
  • react 번들링 파일의 i18n 파일은 public/assets/i18n/web 폴더 하위에 위치한다.

libs/shared/src/lib/configuration/config.model.ts 의 GatewayConfiguration에 I18N_LANG 을 추가한다.

// config.model.ts
export interface MicroServiceConfiguration {
  REVERSE_CONTEXT?: string;
  REVERSE_ADDRESS?: string;
  HTTP_PORT?: number,
  TCP_HOST?: string;
  TCP_PORT?: number,
  GLOBAL_API_PREFIX?: string;
  AUTH?: AuthConfig;
  I18N_LANG?: string; // <== 요기
  I18N_JSON_PATH?: string; // <== 요기
}

export interface GatewayConfiguration {
  HTTP_PORT?: number,
  DASHBOARD?: MicroServiceConfiguration;
  CONFIGURATION?: MicroServiceConfiguration;
  BACK_OFFICE?: MicroServiceConfiguration;
  AUTH?: AuthConfig;
  I18N_LANG?: string; // <== 요기
  I18N_JSON_PATH?: string; // <== 요기
}

apps/gateway/api/src/environments/config.json 파일에 환경을 설정한다. 

// config.json
{
  "HTTP_PORT": 8000,
  "AUTH": {
    "SECRET": "iot_secret_auth",
    "EXPIRED_ON": "1d",
    "REFRESH_SECRET": "iot_secret_refresh",
    "REFRESH_EXPIRED_ON": "7d"
  },
  "I18N_LANG": "en",
  "I18N_JSON_PATH": "/public/assets/i18n/api/",
  ...
}

i18n 파일을 apps/gateway/api/src/public/assets/i18n/api/en(ko)/message.json 파일을 생성하고, 설정한다. 

{
  "USER_NOT_EXIST": "User {username} with this id does not exist"
}

다음으로 libs/shared 쪽에 libs/shared/src/lib/i18n/translater.service.ts 파일을 생성한다.

// translater.service.ts
import { Injectable } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';

@Injectable()
export class TranslaterService {
  constructor(private readonly i18nService: I18nService) { }

  async message(key: string, message: (string | { [k: string]: any; })[] | { [k: string]: any; }): Promise<string> {
    return this.i18nService.translate(`message.${key}`, { args: message });
  }
}

translater module도 libs/shared/src/lib/i18n/translater.module.ts 파일도 생성한다. 

// translater.module.ts
import { join } from 'path';
import { Module } from '@nestjs/common';
import { I18nModule, I18nJsonParser } from 'nestjs-i18n';
import { loadConfigJson } from '@rnm/shared';
import { TranslaterService } from './translater.service';

const config: any = loadConfigJson();

@Module({
  imports: [
    I18nModule.forRoot({
      fallbackLanguage: config.I18N_LANG,
      parser: I18nJsonParser,
      parserOptions: {
        path: join(__dirname, config.I18N_JSON_PATH),
      },
    })
  ],
  providers: [TranslaterService],
  exports: [TranslaterService]
})
export class TranslaterModule { }


// libs/shared/src/index.ts 안에 export도 추가한다. 
export * from './lib/i18n/translater.service';
export * from './lib/i18n/translater.module';

이제 사용을 해본다.

  • apps/gateway/api/src/app/app.module.ts 에 TranslaterModule을 추가한다.
  • apps/gateway/api/src/app/app.controller.ts 에 Service를 사용한다. translate key로는 [fileName].[jsonKey] 를 넣는다. 
// app.module.ts
import { TranslaterModule } from '@rnm/shared';
@Module({
  imports: [
    ...
    // i18n
    TranslaterModule,
    ...
}


// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { GatewayApiAppService } from '@rnm/domain';
import { TranslaterService } from '@rnm/shared';

@Controller('api/gateway')
export class AppController {
  constructor(
    private readonly appService: GatewayApiAppService,
    private readonly translater: TranslaterService
  ) { }

  @Get()
  getData() {
    return this.translater.message('USER_NOT_EXIST', { username: 'Peter Yun' });
  }
}

Gateway API를 디버깅 시작하고, 호출 테스트한다.  Forbidden 에러가 떨어지면 app.module.ts의 AuthMiddleware 경로에서 잠시 "/api*" 설정을 제거후 테스트 한다. 

// apps/gateway/api/src/app/app.module.ts
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .forRoutes(...[
        { path: '/dashboard*', method: RequestMethod.ALL },
        { path: '/configuration*', method: RequestMethod.ALL },
        { path: '/back-office*', method: RequestMethod.ALL },
        // { path: '/api/*', method: RequestMethod.ALL }, <== 요기
      ]);
  }
}

맵핑되어 정보가 나옴

에러 메세지에 대해 Global Exception에 적용해 본다. 

import { Request, Response } from 'express';
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { TranslaterService } from '../i18n/translater.service';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly translater: TranslaterService) { }

  // async로 Promise 반환
  async catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    let message = (exception as any).message;
    // key, args가 있으면 translater
    if (message && message.key && message.args) {
      message = await this.translater.message(message.key, message.args);
    }
    ...
  }
}

 

 

React에 i18n 적용

react-i18next를 사용한다.

$> yarn add react-i18next i18next i18next-xhr-backend

i18n 설정을 위해 apps/gateway/web/src/app/core/i18n.ts 파일을 생성한다. 

// i18n.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import XHR from 'i18next-xhr-backend';

const backendOptions = {
  loadPath: '/assets/i18n/web/locale-{{lng}}.json',
  crossDomain: true,
};

i18next
  .use(XHR)
  .use(initReactI18next)
  .init({
    backend: backendOptions,
    debug: true,
    lng: 'en',
    fallbackLng: false,
    react: {
      useSuspense: true
    }
  });

export default i18next;

설정파일을 apps/gateway/web/src/assets/i18n/web/locale-en.json 을 생성한다. 

{
  "LOGIN": {
    "USERNAME": "Username",
    "PASSWORD": "Password"
  }
}

apps/gateway/web/src/app/app.tsx 파일에 i18n 파일을 로딩한다. 

// app.tsx
import { Suspense } from 'react';
import styles from './app.module.scss';
import Login from './login/login';

import './core/i18n';

const Loader = () => (
  <div className={styles.loading}>
    {/* <img src={logo} className="App-logo" alt="logo" /> */}
    <div>loading...</div>
  </div>
);

export function App() {
  return (
    <Suspense fallback={<Loader />}>
      <Login />;
    </Suspense>
  );
}
export default App;

apps/gateway/web/src/app/login/login.tsx 에서 useTranslation() hook을 사용한다. 

import { Row, Col, Form, Input, Button } from 'antd';
// import 
import { useTranslation } from 'react-i18next';
...

function Login() {
  const { t, i18n } = useTranslation();
  return (
    <div className={styles.login_container}>
     ...
              // t를 통해 translation
              <Form.Item
                label={t('LOGIN.USERNAME')}
                name="username"
                rules={[{ required: true, message: 'Please input your username!' }]}
              >
                <Input />
              </Form.Item>

              <Form.Item
                label={t('LOGIN.PASSWORD')}
                name="password"
                rules={[{ required: true, message: 'Please input your password!' }]}
              >
                <Input.Password />
              </Form.Item>
     ...
   </div>
  );
}

 

 

Configuration 리팩토링

NestJS에서 사용하는 config.json 파일을 한번만 로딩하도록 libs/shared/src/lib/configuration/config.service.ts 파일을 리팩토링한다. 

// config.service.ts
import * as fs from "fs";
import { join } from 'path';
import { GatewayConfiguration, MicroServiceConfiguration, OrmConfiguration } from "./config.model";

export const loadConfigJson = (message = '[LOAD] config.json file'): MicroServiceConfiguration | GatewayConfiguration => {
  let config: any = process.env.config;
  if (!config) {
    console.log(`${message}:`, `${__dirname}/environments/config.json`);
    const jsonFile = fs.readFileSync(join(__dirname, 'environments', 'config.json'), 'utf8');
    process.env.config = jsonFile;
    config = JSON.parse(jsonFile);
  } else {
    config = JSON.parse(process.env.config as any);
  }
  return config;
}

export const loadOrmConfiguration = (message = '[LOAD] orm-config.json file'): OrmConfiguration => {
  let config: any = process.env.ormConfig;
  if (!config) {
    console.log(`${message}:`, `${__dirname}/environments/orm-config.json`);
    const jsonFile = fs.readFileSync(join(__dirname, 'environments', 'orm-config.json'), 'utf8');
    process.env.ormConfig = jsonFile;
    config = JSON.parse(jsonFile);
  } else {
    config = JSON.parse(process.env.ormConfig as any);
  }
  return config;
}

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-10

 

 

<참조>

- nestjs-i18n 적용하기 

https://github.com/ToonvanStrijp/nestjs-i18n

 

GitHub - ToonvanStrijp/nestjs-i18n: Add i18n support inside your nestjs project

Add i18n support inside your nestjs project. Contribute to ToonvanStrijp/nestjs-i18n development by creating an account on GitHub.

github.com

- react best i18n libraries 

https://phrase.com/blog/posts/react-i18n-best-libraries/

 

Curated List: Our Best of Libraries for React I18n – Phrase

There may be no built-in solution for React i18n, but these amazing libraries will help you manage your i18n projects from start to finish.

phrase.com

- react-i18next 공식 홈페이지

https://react.i18next.com/

 

Introduction

 

react.i18next.com

- i18next의 react 사용예

https://github.com/i18next/react-i18next/blob/master/example/react/src/App.js

 

GitHub - i18next/react-i18next: Internationalization for react done right. Using the i18next i18n ecosystem.

Internationalization for react done right. Using the i18next i18n ecosystem. - GitHub - i18next/react-i18next: Internationalization for react done right. Using the i18next i18n ecosystem.

github.com

 

posted by 윤영식
2021. 9. 30. 19:48 React/Architecture

React로 Login 화면을 개발한다. 

 

 

Gateway의 api server와 web dev server의 연결

Gateway를 개발환경에서 api server를 구동하고, web 화면 테스트를 위하여 web dev server가 구동하면 web dev server의 요청이 api server로 proxy 되어야 한다. 

  • apps/gateway/web/proxy.conf.json 파일을 생성한다.
  • apps/gateway/web/project.json에 "proxyConfig" 위치와 "port"는 7000 번으로 설정한다.
// project.json 일부분 

"serve": {
      "executor": "@nrwl/web:dev-server",
      "options": {
        "buildTarget": "gateway-web:build",
        "hmr": true,
        "proxyConfig": "apps/gateway/web/proxy.conf.json", <== 요기
        "port": 7000  <== 요기
      },
      ...
}

proxy.conf.json 내역

{
  "/gateway/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/api/gateway/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/api/auth/login": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/dashboard/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/dashboard/api/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/configuration/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/configuration/api/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/back-office/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  },
  "/back-office/api/*": {
    "target": "http://localhost:8000",
    "secure": false,
    "changeOrigin": true,
    "ws": true
  }
}

 

 

Web 패키지들 설치

rnn-stack의 글을 참조한다.

  • antd components 패키지 설치: "yarn add antd @ant-design/icons"
  • React Router 설치: "yarn add react-router react-router-dom"
  • Axios 설치: "yarn add axios"

VSCode의 디버깅 실행 환경파일인 launch.json파일에 Web Dev Server를 수행할 수 있도록 설정한다. VSCode에서 실행보다는 별도 terminal 에서 수행하는 것을 권장하고, 옵션으로 사용한다.

// launch.json 일부 내역 
    {
      "type": "node",
      "request": "launch",
      "name": "Gateway API",
      "program": "${workspaceFolder}/apps/gateway/api/src/main.ts",
      "preLaunchTask": "build:gateway-api",
      "outFiles": ["${workspaceFolder}/dist/apps/gateway/api/**/*.js"]
    },
    // 하기 내역
    {
      "type": "node",
      "request": "launch",
      "name": "Gatewy Web",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "start:gateway-web"],
      "outFiles": ["${workspaceFolder}/dist/apps/gateway/web/**/*.js"]
    },

npm의 script로 "start:gateway-web"을 설정한다.

// package.json 일부 

  "scripts": {
    "build:gateway-api": "nx build gateway-api",
    "start:gateway-api": "nx serve gateway-api",
    "build:gateway-web": "nx build gateway-web",
    "start:gateway-web": "nx serve gateway-web",
    ....
  },

이제 VS Code에서 Gateway web을 실행하고, http://localhost:7000/ 호출한다.

Gateway Web Dev Server 기동

 

 

Login 화면 저작

nx 명령을 이용해 login 컴포넌트를 생성한다.

$> nx g @nrwl/react:component login --project=gateway-web

생성된 파일들

login.tsx 내역

// login.tsx
import { Row, Col, Form, Input, Button } from 'antd';
import { httpService } from '@rnm/ui';
import { LoginDto } from '@rnm/model';
import styles from './login.module.scss';

export function Login() {
  const onFinish = (user: LoginDto) => {
    httpService.post<LoginDto>('/api/auth/login', user).subscribe((result: LoginDto) => {
      console.log('Success:', result);
      // redirect dashboard
      location.href = '/dashboard';
    });
  };

  const onFinishFailed = (errorInfo: any) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <div className={styles.login_container}>
      <div className={styles.center_bg}>
        <Row justify="center" align="middle" className={styles.form_container}>
          <Col span={16} offset={6}>
            <Form
              name="basic"
              layout="vertical"
              labelCol={{ span: 16 }}
              wrapperCol={{ span: 16 }}
              initialValues={{ remember: true }}
              onFinish={onFinish}
              onFinishFailed={onFinishFailed}
              autoComplete="off"
            >
              <Form.Item
                label="Username"
                name="username"
                rules={[{ required: true, message: 'Please input your username!' }]}
              >
                <Input />
              </Form.Item>

              <Form.Item
                label="Password"
                name="password"
                rules={[{ required: true, message: 'Please input your password!' }]}
              >
                <Input.Password />
              </Form.Item>

              <Form.Item wrapperCol={{ span: 16 }}>
                <Button type="primary" htmlType="submit" block>
                  Submit
                </Button>
              </Form.Item>
            </Form>
          </Col>
        </Row>
      </div>
    </div>
  );
}

export default Login;

 

 

Gateway API 서버 호출하기

Web 에서 사용할 라이브러리는 분리하고, API와 Web이 공용하는 부분은 Model에만 국한한다. 따라서 @rnm/model 패키지를 생성한다. 

$> nx g @nrwl/react:lib model --publishable --importPath=@rnm/model

domain/entities/user/user.model.ts 을 libs/model/src/lib/user/user.model.ts 로 copy하고, domain에서는 삭제한다. 그리고 user.model사용하는 클래스를 일괄 수정한다. 

// user.model.ts
export interface User {
  id?: number;
  username: string;
  password?: string;
  email?: string;
  firstName?: string;
  lastName?: string;
  role?: string;
  sub?: string | number;
  currentHashedRefreshToken?: string;
}
export type LoginDto = Pick<User, 'username' | 'password'>;
export type TokenPayload = Omit<User, 'password'>;

export enum UserRole {
  ADMIN = 'ADMIN',
  MANAGER = 'MANAGER',
  CUSTOMER = 'CUSTOMER',
  GUEST = 'GUEST',
}

 

다음으로 libs/ui/src/lib/ajax/http.service.ts 파일을 생성하고, http.service.ts 코드에, error처리 notification을 추가한다.

// http.service.ts 일부
  import { notification } from 'antd';
  
  ...
  private executeRequest<T>(args: RequestArgs): Observable<T> {
    const { method, url, queryParams, payload } = args;
    let request: AxiosPromise<T>;
    switch (method) {
      case HttpMethod.GET:
        request = this.httpClient.get<T>(url, { params: queryParams });
        break;
      case HttpMethod.POST:
        request = this.httpClient.post<T>(url, payload);
        break;
      case HttpMethod.PUT:
        request = this.httpClient.put<T>(url, payload);
        break;
      case HttpMethod.PATCH:
        request = this.httpClient.patch<T>(url, payload);
        break;
      case HttpMethod.DELETE:
        request = this.httpClient.delete<T>(url);
        break;
    }

    return new Observable<T>((observer: Observer<T>) => {
      request
        .then((response: AxiosResponse) => {
          observer.next(response.data);
        })
        .catch((error: AxiosError | Error) => {
          this.abort(true);
          if (axios.isAxiosError(error)) {
            if (error.response) {
              // NestJS의 global-exception.filter.ts의 포멧
              const data: any = error.response?.data || {};
              this.showNotification(`[${data.statusCode}] ${data.error}`, data.message);
              console.log(`[${data.statusCode}] ${data.error}: ${data.message}`);
            }
          } else {
            this.showNotification('Unknow Error', error.message);
            console.log(error.message);
          }
        })
        .finally(() => {
          this.completed = true;
          observer.complete();
        });
      return () => this.abort();
    });
  }

  private showNotification(message: string, description: string): void {
    notification.error({
      message,
      description
    });
  }
}

에러처리는 libs/shared/src/lib/filter/global-exception.filter.ts 의 에러 포멧을 따른다. 

import { Request, Response } from 'express';
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const message = (exception as any).message;

    Logger.error(message, (exception as any).stack, `${request.method} ${request.url}`);

    const name = exception?.constructor?.name || 'HttpException';
    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    switch (name) {
      case 'HttpException':
        status = (exception as HttpException).getStatus();
        break;
      case 'UnauthorizedException':
        status = HttpStatus.UNAUTHORIZED;
        break;
      case 'ForbiddenException':
        status = HttpStatus.FORBIDDEN;
        break;
      case 'QueryFailedError':  // this is a TypeOrm error
        status = HttpStatus.UNPROCESSABLE_ENTITY;
        break;
      case 'EntityNotFoundError':  // this is another TypeOrm error
        status = HttpStatus.UNPROCESSABLE_ENTITY;
        break;
      case 'CannotCreateEntityIdMapError': // and another
        status = HttpStatus.UNPROCESSABLE_ENTITY;
        break;
      default:
        status = HttpStatus.INTERNAL_SERVER_ERROR;
    }

	// 에러 리턴 포멧
    response.status(status).json(
      {
        statusCode: status,
        error: name,
        message,
        method: request.method,
        path: request.url,
        timestamp: new Date().toISOString()
      }
    );
  }
}

 

테스트 진행시 UI가 Nest쪽 패키지를 사용하면 번들링 오류가 발생할 수 있다. 따라서 libs 하위의 패키지들은 향후 API용, WEB용 구분하여 사용하고, model 패키지만 공용으로 사용한다. API용, WEB용을 구분한다면 하기와 같이 별도 폴더로 묶어 관리하는게 좋아 보인다. 

api, web, model 분리

 

Nx 기반 library 생성 명령은 다음과 같다. 

// api library
$>  nx g @nrwl/nest:lib api/shared --publishable --importPath=@rnm/api-shared
$>  nx g @nrwl/nest:lib api/domain --publishable --importPath=@rnm/api-domain

// web library
$> nx g @nrwl/react:lib web/shared --publishable --importPath=@gv/web-shared
$> nx g @nrwl/react:lib web/domain --publishable --importPath=@gv/web-domain
$> nx g @nrwl/react:lib web/ui --publishable --importPath=@gv/web-ui

// model library
$> nx g @nrwl/nest:lib model --publishable --importPath=@gv/model

라이브러 생성 폴더 구조

 

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-9

 

Release ms-9 · ysyun/rnm-stack

[ms-9] added login component and enhanced ajax error notification

github.com

 

 

<참조>

- React 라이브러리 환경 구성

https://mobicon.tistory.com/580

 

[React HH-3] 라이브러리 설정 - Axios, RxJS

React 외에 애플리케이션 개발을 위한 라이브러리를 설치한다. UI Components PrimeReact, EUI, MaterialUI, AntD 검토후 소스레벨 최신으로 반영하고 있고, 다양한 비즈니스 UX 대응 가능한 AntD를 선택한다. //..

mobicon.tistory.com

- location.href와 location.replace 차이점

https://opentutorials.org/module/2919/22904

 

location.href 와 location.replace 차이점 - JavaScript Tips

[출처] [자바스크립트] location.href 와 location.replace 의 차이점.|작성자 왕따짱 location.href location.replace   기능 새로운 페이지로 이동된다. 기존페이지를 새로운 페이지로 변경시킨다.   형태 속

opentutorials.org

 

posted by 윤영식
2021. 9. 30. 13:22 React/Architecture

 

NestJS에서 제공하는 Auth와 Role 기능을 확장해 본다. NestJS는 그외 Configuration, Logging, Filter, Interceptor등 다양한 기능을 확장하여 적용할 수 있도록 한다. 

 

 

Role 데코레이터 추가

Role 체크를 위한 데코레이터를 libs/shared/src/lib/decorator/roles.decorator.ts 를 추가한다.

// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

 

 

Role 가드 추가

request시에 user 정보의 role을 통해 match되는지를 체크하는 가드(guard)를 libs/shared/src/lib/guard/role.guard.ts 추가한다. 

  • 요구하는 roles가 없으면 bypass 한다.
  • user가 없다면 즉, 로그인한 사용자가 아니거나, Login Token이 없다면 Forbidden 에러를 발생시킨다.
// role.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

const matchRoles = (roles: string[], userRoles: string) => {
  return roles.some(role => role === userRoles);
};

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) { }

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }

    const req = context.switchToHttp().getRequest() as any;
    const user = req.user;
    if (!user) {
      throw new ForbiddenException('User does not exist');
    }
    return matchRoles(requiredRoles, user.role);
  }
}

 

로그인후 express의 request에 user 객체 할당

로그인을 하면 사용자 정보가 Token에 담긴다. @Role 데코레이터를 체크하기 전에 Token 정보를 기반으로 user 정보를 추출한다. 

  • 로그인 토큰: LOGIN_TOKEN

libs/domain/src/lib/auth/auth.middleware.ts 파일을 생성하고, 쿠키의 LOGIN_TOKEN에서 user정보를 얻는다.

import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { verify } from 'jsonwebtoken';

import { loadConfigJson } from '@rnm/shared';
const config: any = loadConfigJson();

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    if (req.user) {
      next();
      return;
    }
    
    const accessToken = req?.cookies?.LOGIN_TOKEN;
    let user;
    try {
      user = verify(
        accessToken,
        config?.AUTH?.SECRET,
      );
    } catch (error) {
      throw new ForbiddenException('Please register or sign in.');
    }

    if (user) {
      req.user = user;
    }
    next();
  }
}

request에 user를 할당하는 미들웨어와 Role Guard를 apps/gateway/api/src/app/app.module.ts 에 설정한다. 

  • RolesGuard 등록
  • AuthMiddleware path들 등록
// app.module.ts
import { join } from 'path';
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';

import { GatewayApiAppService, EntitiesModule, AuthModule, AuthMiddleware } from '@rnm/domain';
import { GlobalExceptionFilter, ormConfigService, RolesGuard } from '@rnm/shared';

import { DashboardModule } from './dashboard/microservice/dashboard.module';
import { ConfigurationModule } from './configuration/microservice/configuration.module';
import { BackOfficeModule } from './back-office/microservice/back-office.module';
import { AppController } from './app.controller';
import { AuthController } from './auth/auth.controller';
import { UserController } from './user/user.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      exclude: [
        '/api/auth*',
        '/api/gateway*', '/api/dashboard*', '/api/configuration*', '/api/back-office*',
        '/dashboard*', '/configuration*', '/back-office*'
      ],
    }),
    // ORM
    TypeOrmModule.forRoot({
      ...ormConfigService.getTypeOrmConfig(),
      entities: getMetadataArgsStorage().tables.map(tbl => tbl.target)
    }),
    EntitiesModule,
    // MicroService
    DashboardModule,
    ConfigurationModule,
    BackOfficeModule,
    // Auth
    AuthModule
  ],
  controllers: [
    AuthController,
    AppController,
    UserController
  ],
  providers: [
    GatewayApiAppService,
    // Global Exception Filter
    {
      provide: APP_FILTER,
      useClass: GlobalExceptionFilter,
    },
    // 1) Role Guard 등록
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ]
})
export class AppModule implements NestModule {
  // 2) Auth Middleware 등록
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .forRoutes(...[
        { path: '/dashboard*', method: RequestMethod.ALL },
        { path: '/configuration*', method: RequestMethod.ALL },
        { path: '/back-office*', method: RequestMethod.ALL },
        { path: '/api*', method: RequestMethod.ALL },
      ]);
  }
}

 

 

Role 사용하기

user 테이블에 Role이 저장되어있다. 

user.model.ts 소스에 UserRole enum을 추가한다. 

// user.model.ts
export interface User {
  id?: number;
  username: string;
  password?: string;
  email?: string;
  firstName?: string;
  lastName?: string;
  role?: string;
  sub?: string | number;
  currentHashedRefreshToken?: string;
}
export type LoginDto = Pick<User, 'username' | 'password'>;
export type TokenPayload = Omit<User, 'password'>;

// User Role
export enum UserRole {
  ADMIN = 'ADMIN',
  MANAGER = 'MANAGER',
  CUSTOMER = 'CUSTOMER',
  GUEST = 'GUEST',
}

apps/gateway/api/src/app/user/user.controller.ts 안에 @Roles을 적용한다. 

// user.controller.ts
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';

import { JwtAuthGuard, UserService } from '@rnm/domain';
import { User, UserRole } from '@rnm/model';
import { Roles } from '@rnm/shared';

@Controller('api/gateway/user')
export class UserController {
  constructor(
    private readonly service: UserService
  ) { }

  @UseGuards(JwtAuthGuard)
  @Post()
  @Roles(UserRole.ADMIN, UserRole.MANAGER) // <== 요기
  async create(@Body() data: User): Promise<User> {
    const savedUser = await this.service.create(data);
    if (!savedUser) {
      return;
    }
    return savedUser;
  }
  ....
 }

 

이후 열심히 사용해 보자.

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-8

 

Release ms-8 · ysyun/rnm-stack

[ms-8] added role guard for authorization

github.com

 

 

<참조>

- NestJS Authorization: https://docs.nestjs.kr/security/authorization

 

네스트JS 한국어 매뉴얼 사이트

네스트JS 한국, 네스트JS Korea 한국어 매뉴얼

docs.nestjs.kr

- JWT Role based authentication: https://github.com/rangle/jwt-role-based-authentication-examples

 

GitHub - rangle/jwt-role-based-authentication-examples: Implement the same backend using graphql, nestjs and deno.

Implement the same backend using graphql, nestjs and deno. - GitHub - rangle/jwt-role-based-authentication-examples: Implement the same backend using graphql, nestjs and deno.

github.com

 

posted by 윤영식
2021. 9. 30. 13:22 React/Architecture

Login Auth Token이 만료되었을 때 Refresh Token을 통하여 다시 Auth Token을 생성토록한다. 

  • Refresh Token을 서버에 저장한다. 다른 기기에서 로그인하면 기존 로그인 기기의 Refresh Token과 비교하여 틀리므로 여러 기기의 로그인을 방지한다. 
  • 서버에 여러개의 Refresh Token을 저장할 수 있다면 기기 제한을 할 수 있겠다. 또한 변조된 refresh token의 사용을 막을 수 있다.

 

User Entity 업데이트

libs/domain/src/lib/entities/user/user.entity.ts 에 refreshToken 컬럼을 추가한다. 

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';

@Entity('user_iot')
export class UserEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true, length: 50 })
  username!: string;

  @Column()
  password!: string;

  @Column({ length: 255 })
  email!: string;

  @Column({ name: 'first_name', length: 100 })
  firstName!: string;

  @Column({ name: 'last_name', length: 100 })
  lastName!: string;

  @Column({ default: 'GUEST' })
  role!: string;

  @CreateDateColumn({ name: 'created_at', select: false })
  createdAt?: Date;

  @CreateDateColumn({ name: 'updated_at', select: false })
  updatedAt?: Date;

  // refresh token 저장
  @Column({
    name: 'current_hashed_refresh_token',
    nullable: true
  })
  currentHashedRefreshToken?: string;

}

 

 

User Service에 refreshToken 매칭

Cookie의 REFRESH_TOKEN이 서버에 저장된 값과 맞으면 해당 user정보를 반환하는 코드를 libs/domain/src/lib/entities/user/user.service.ts 에 추가한다.

// user.service.ts 일부

  async getUserIfRefreshTokenMatches(refreshToken: string, id: number): Promise<User | undefined> {
    const user = await this.findOneById(id);

    const isRefreshTokenMatching = await bcryptCompare(
      refreshToken,
      user.currentHashedRefreshToken as string
    );

    if (isRefreshTokenMatching) {
      return user;
    }
    return;
  }

 

 

JWT Refresh Strategy와 Guard 추가

Guard에서 사용할 Refresh Strategy를 libs/domain/src/lib/auth/strategies/jwt-refresh.strategy.ts 파일 생성후 추가한다.

import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

import { UserService } from '@rnm/domain';
import { loadConfigJson } from '@rnm/shared';
import { TokenPayload, User } from '@rnm/model';

const config: any = loadConfigJson();

@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh-token') {
  constructor(
    private readonly userService: UserService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.REFRESH_LOGIN_TOKEN;
      }]),
      secretOrKey: config?.AUTH?.REFRESH_SECRET,
      passReqToCallback: true,
    });
  }

  async validate(request: Request, payload: TokenPayload): Promise<User | undefined> {
    const refreshToken = request.cookies?.REFRESH_LOGIN_TOKEN;
    return this.userService.getUserIfRefreshTokenMatches(refreshToken, payload.id as number);
  }
}

Refresh Guard도 libs/domain/src/lib/auth/guards/jwt-auth-refresh.guard.ts 파일 생성하고 추가한다. 

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') { }

파일 추가후에는 항시 libs/domain/src/index.ts 안에 export를 해야 한다. 

export * from './lib/constants/core.contant';
export * from './lib/entities/user/user.entity';
export * from './lib/entities/user/user.service';
export * from './lib/entities/entity.module';

export * from './lib/models/request.model';

export * from './lib/auth/auth.service';
export * from './lib/auth/auth.middleware';
export * from './lib/auth/auth.module';
export * from './lib/auth/guards/local-auth.guard';
export * from './lib/auth/guards/jwt-auth.guard';
export * from './lib/auth/guards/jwt-auth-refresh.guard'; // <== 요기

export * from './lib/auth/strategies/local.strategy';
export * from './lib/auth/strategies/jwt.strategy';
export * from './lib/auth/strategies/jwt-refresh.strategy'; // <== 요기

export * from './lib/service/gateway/api/service/gateway-api-app.service';
export * from './lib/service/dashboard/api/service/dashboard-api-app.service';
export * from './lib/configuration/api/service/configuration-api-app.service';
export * from './lib/service/back-office/api/service/backoffice-api-app.service';

 

 

RefreshToken과 AuthToken을 Cookie에 실어 보내기

두가지 Token을 response cookie에 실어 보내기위해 먼저 cookie 생성하는 코드를  libs/domain/src/lib/auth/auth.service.ts 에 추가한다. 

// auth.service.ts 일부
  getCookieWithJwtAccessToken(payload: TokenPayload, hasAuthorization = false) {
    const token = this.jwtService.sign(payload, {
      secret: config?.AUTH?.SECRET || 'iot_app',
      expiresIn: config?.AUTH?.EXPIRED_ON || '1d'
    });
    if (hasAuthorization) {
      return [`LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`, `Authorization=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`];
    } else {
      return [`LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`];
    }
  }

  getCookieWithJwtRefreshToken(payload: TokenPayload) {
    const token = this.jwtService.sign(payload, {
      secret: config?.AUTH?.REFRESH_SECRET || 'io_app_refresh',
      expiresIn: config?.AUTH?.REFRESH_EXPIRED_ON || '7d'
    });
    const cookie = `REFRESH_LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.REFRESH_EXPIRED_ON}`;
    return {
      cookie,
      token
    }
  }

  getCookiesForLogOut() {
    return [
      'LOGIN_TOKEN=; HttpOnly; Path=/; Max-Age=0',
      'REFRESH_LOGIN_TOKEN=; HttpOnly; Path=/; Max-Age=0'
    ];
  }

로apps/gateway/api/src/app/auth/auth.controller.ts 안에 하기 로직을 추가한다.

  • 로그인 했을 때 해당 Cookie를 등록한다. 
  • 로그아웃할 때 해당 Cookie 내용을 삭제한다. 
  • Auth Token (Forbidden)오류 발생시 RefreshToken을 통해 Auth Token 재생성한다. 
import { Controller, Get, HttpCode, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';

import { AuthService, JwtAuthGuard, LocalAuthGuard, JwtRefreshGuard, RequestWithUser, UserService } from '@rnm/domain';
import { TokenPayload } from '@rnm/model';

@Controller('api/auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly userService: UserService
  ) { }

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Req() req: RequestWithUser): Promise<any> {
    const { user } = req;
    if (user) {
      const payload: TokenPayload = { username: user.username, sub: user.id, email: user.email, role: user.role };
      const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(payload);
      const {
        cookie: refreshTokenCookie,
        token: refreshToken
      } = this.authService.getCookieWithJwtRefreshToken(payload);
      const loginUsernameCookie = this.authService.getCookieWithLoginUsername(payload);

      await this.userService.setCurrentRefreshToken(refreshToken, user.id);

      // 반드시 req.res로 쿠키를 설정
      req.res.setHeader('Set-Cookie', [...accessTokenCookie, refreshTokenCookie, loginUsernameCookie]);
      return {
        payload,
        accessTokenCookie,
        refreshTokenCookie
      };
    } else {
      throw new UnauthorizedException({
        error: 'User does not exist'
      });
    }
  }

  @UseGuards(JwtAuthGuard)
  @Post('logout')
  @HttpCode(200)
  async logout(@Req() req: RequestWithUser, @Res() res): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.setHeader('Set-Cookie', this.authService.getCookiesForLogOut());
  }

  @UseGuards(JwtAuthGuard)
  @Get()
  authenticate(@Req() req: RequestWithUser) {
    const user = req.user;
    return user;
  }

  // Refresh Guard를 적용한다.
  @UseGuards(JwtRefreshGuard)
  @Get('refresh')
  refresh(@Req() request: RequestWithUser) {
    const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(request.user);

    request.res.setHeader('Set-Cookie', accessTokenCookie);
    return request.user;
  }
}

 

 

테스트하기 

Postman으로 테스트를 하면 accessTokenCookie가 나온다. 

로그인 결과값

accessTokenCookie를 복사하여 다른 명령 전송시에 Headers에 Cookie를 등록하여 사용한다. 

복사한 accessTokenCookie 사용

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-7

 

Release ms-7 · ysyun/rnm-stack

[ms-8] added role guard for authorization

github.com

 

 

<참조>

- Refresh Token 만들기
   소스: https://github.com/mwanago/nestjs-typescript

   문서: https://wanago.io/2020/09/21/api-nestjs-refresh-tokens-jwt/

 

API with NestJS #13. Implementing refresh tokens using JWT

It leaves quite a bit of room for improvement. In this article, we look into refresh tokens.

wanago.io

 

posted by 윤영식