본문 바로가기

Back-End

[Nodejs] 프로그래머스 우리싸인 API 서버 개발 과제분석[3]

728x90

이전 글에 이어 요건 4번부터 진행해 보겠습니다.

 

요건 4. 보안

문서 정보가 노출되면 안 되기 때문에 보안에 각별히 신경을 써야 합니다.

4.1. 사이트 간 요청 위조 (Cross-site request forgery, CSRF, XSRF) 차단

 

src/middlewares/csrf.middleware를 구현해 주세요.

express-session과 csrf 라이브러리가 이미 설치되어 있습니다. 해당 라이브러리를 이용해서 csrf 미들웨어를 작성해 주세요.

csrf 라이브러리 문서는 여기에서 확인할 수 있습니다.

  • 요청이 들어올 때마다 token을 생성해서 응답 해더 x-csrf-token로 내려주세요.
  • /api path로 요청이 오면, secret을 생성해서 세션에 저장해 주세요
  • /api path가 아닌 요청이 오면, x-csrf-token 해더값을 검증해 주세요.
  • 마지막 token을 세션에 저장하고 해더값을 검증할 때 토큰 값이 같은지 비교해 주세요.

Exception:

  • 세션에 csrfSecret 정보가 없는 경우 (401)
  • 해더 값이 세션에 저장된 csrf token과 같지 않은 경우 (403)
  • csrf 토큰 정보가 유효하지 않은 경우 (403)

 

csrf 공격은 간단하게 말하면

사용자 A가 처음 접속했을 때 혹은 정상적으로 접속했을 때 A에게 특정한 값을 주고 서버(세션)에도 그 특정한 값을 저장해 둡니다. 

만약 A가 피싱 사이트에 잘못 접속하여 그 피싱 사이트에서 이 사용자 A의 정보를 이용해 우리 서버에 api 요청을 보내온다면 서버 입장에선 저 특정한 값이 있는지 없는지에 따라 이 사용자가 진짜 A인지 구별해 냅니다.

 

//csrf.middleware.ts
import { Handler } from "express";
import Tokens from "csrf";
import {
  ForbiddenException,
  UnauthorizedException,
} from "../common/exceptions";

export const CSRF_TOKEN_HEADER = "x-csrf-token";

const ignorePaths = ["/api"];
const tokens = new Tokens();

export const csrf = (): Handler => (req, res, next) => {
  const sec = tokens.secretSync();
  const nToken = tokens.create(sec);
  res.setHeader(CSRF_TOKEN_HEADER, nToken); //응답 헤더에 토큰을 내려줍니다.

  if (req.path !== ignorePaths[0]) {
    //헤더 검증
    const clientToken = req.headers[CSRF_TOKEN_HEADER] as string; //클라이언트가 보낸 헤더에서 토큰 값을 추출합니다.
    if (!clientToken ||
      (req.session.csrfToken && req.session.csrfToken !== clientToken)
    ) //토큰 값이 없거나 세션에 담겨있는 토큰 값과 다르다면 에러를 뱉습니다.
      throw new ForbiddenException();
    if (!req.session.csrfSecret) throw new UnauthorizedException(); //세션에 담긴 secretKey가 없다면 에러를 뱉습니다.
  } else {
    //정상적인 요청이라면 세션에 secretKey를 담아줍니다.
    req.session.csrfSecret = sec;
  }

  req.session.csrfToken = nToken; //세션에 토큰 값을 담아줍니다.

  next();
};

 

한 가지 특이한 점은, 요청 url(path)가 /api 일 때는 검증을 하지 않습니다. 이유를 생각해 보면

/api로 요청이 왔을 때 수행되는 라우터는 document.controller.ts의 create와 findAll인데

initializeRoutes() {
    const router = Router();

    router
      .post("/", wrap(this.create))
      .get("/", wrap(this.findAll))
      .get("/:documentId", wrap(this.findOne))
      .delete("/:documentId", wrap(this.remove))
      .post("/:documentId/publish", wrap(this.publish));

    this.router.use(this.path, router);
  }

아마 거짓 요청을 진짜 요청으로 착각하고 리턴을 줘도 크게 리스크 하지 않은 프로세스이기 때문인 것과 가장 기본적인 혹은 처음으로 들어오는 요청이기 때문이지 않을까 싶습니다.

 

그래서 /api로 요청이 오면 secretKeytoken을 생성해 세션에 저장해 두고 응답헤더에도 실어서 보냅니다.

이후 /api ~ 요청이 오면 클라이언트로부터 온 요청에 이 secretKey와 token이 세션에 저장된 값과 동일한지 체크해 값이 없거나 동일하지 않으면 에러를 뱉도록 구현했습니다.

 

 

남아있는 에러 케이스 4건을 살펴보면 1건은 요건 3번에서 진행했던 인증 검증 테스트입니다. 3번 요건에서 /api/users/me 에 대한 내용은 없었기에 넘어갔지만 문제 누락이지 않을까 싶어 해당 라우터에도 동일한 검증 로직을 추가해 주었습니다.

 

//user.controller.ts
me: Handler = (req, res) => {
    if (!req.headers.authorization) throw new UnauthorizedException();

 

4.2. 중복 로그인 방지

UserController.login과 ParticipantController.token 메서드에 코드를 추가해 주세요.

새로운 인증 토큰을 발행할 때 다른 인증 토큰을 만료시켜서 하나의 인증 토큰만 유효하도록 해주세요.

  • 인증 토큰을 발행 후 세션에 인증 정보를 저장합니다.
  • req.sessionStore에서 해당 인증의 다른 세션을 찾아서 제거합니다. (참가자 ID 또는 사용자 ID로 조회)

Exception:

  • 세션 정보가 없을 경우 (401)
  • 세션 정보와 인증 정보가 다른 경우 (403)

 

중복 로그인 방지를 위한 절차는 다음과 같습니다.

1. 사용자가 로그인에 성공하면 세션(sessionStore)에 인증정보를 저장하기 전에,

기존 세션에 이 사용자의 인증정보가 들어있는지 확인합니다.

2. 만약 들어있다면 기존 세션을 제거합니다.

3. 로그인에 성공하면 인증정보를 세션에 다시 담습니다.

 

sessionStore를 찍어보면 다음과 같습니다.

키 값은 sessionId이고 value로 저희 지금까지 작성한 코드 중에 session에 담은 정보들이 들어있습니다.

중복로그인 고객이라면 이 sessionStore에서 본인 이메일로 저장된 데이터가 있을 것이고 현재 인증한 정보(csrfSecret or csrfToken)와 다를 겁니다.

 

저는 이메일을 구분값으로 사용해 기존 인증정보를 찾아내어 csrfSecret 값이 다르면 세션을 파기했습니다.

//user.controller.ts login 
req.sessionStore.all((err, sessions) => {
      for (let sessionId in sessions) {
        if (sessions[sessionId]["email"] === email) {
          if (sessions[sessionId].csrfSecret !== csrfSecret) {
            req.sessionStore.destroy(sessionId, () => {});
          }
        }
      }
    });

동일한 방식으로 participant.controller.ts token 함수 안에도 작성해 주고

 

요건 중에 세션 정보가 없을 경우 Exception(401)을 뱉어야 하므로 user.controller.ts me 함수 안에서 쿠키 값이 있는지 체크합니다.

*테스트 소스를 보면 헤더를 세팅할 때 Cookie 값을 undefined로 만듭니다.

//user.controller.ts me
me: Handler = (req, res) => {
    if (!req.get("Cookie")) throw new UnauthorizedException();

    if (req.headers.authorization === undefined) {
      throw new UnauthorizedException();
    }
    const email = req.session.email;
    const user = this.userService.findByEmail(email);
    return { user: user.toJson() };
  };

 

이로써 개발을 마쳤습니다.

테스트도 마쳤습니다.

 

 


 

이로써 긴 포스팅이 끝났는데 너무 코드 뭉텅이로만 올려놓은 건 아닌가 걱정이 되지만 글솜씨가 없어 이것도 어렵더군요..

 

그래도 누군가에겐 도움 되는 자료였길 바라며..

 

저도 이번에 이 테스트 과제를 풀어보면서 nodejs와 typescript에 대해 많이 공부하고 도움을 받는 좋은 기회였습니다.

이러한 아키텍처와 테스트 소스 파일을 활용해서 실무에도 적용해보려고 합니다.

 

긴 글 읽어주셔서 감사합니다.

 

깃허브 링크 남깁니다.

https://github.com/Shinsieon/Typescript_Wesign

 

GitHub - Shinsieon/Typescript_Wesign: programmers assignment- typescript node server [WeSign]

programmers assignment- typescript node server [WeSign] - Shinsieon/Typescript_Wesign

github.com