이전 글에 이어 요건 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로 요청이 오면 secretKey
와 token
을 생성해 세션에 저장해 두고 응답헤더에도 실어서 보냅니다.
이후 /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
'Back-End' 카테고리의 다른 글
Learn Golang - Chat Service (0) | 2024.03.30 |
---|---|
[Nodejs] 프로그래머스 우리싸인 API 서버 개발 과제분석[2] (0) | 2023.08.05 |
프로그래머스 우리싸인 API 서버 개발 과제분석 (0) | 2023.08.05 |
es6 문법으로 refactoring하기 (0) | 2023.07.30 |
[Linux Centos] offline 환경에서 redis 설치하기 (0) | 2023.06.17 |