회원 로그인/로그아웃 기능 직접 구현(w cookie/session 처리, AES 암복호화)

박재하·2023년 11월 24일
0

목표

  • sessionId 암복호화를 위한 AES 암복호화 모듈 구현
  • cookie 처리 구현
  • session 처리 구현 1 (request 받기)
  • session 처리 구현 2 (response 보내기)
  • 리팩토링: HttpRequest 객체 사용하여 요청 처리
  • 로그인/로그아웃 상태에 따른 header 표시 변경
  • 회원 로그인 기능 구현

고민과 해결 과정

sessionId 암복호화를 위한 AES 암복호화 모듈 구현

쿠키, 세션 파싱을 진행하다보니 sessionId는 express-session처럼(실제로 그런지는 모르겠다. 학습메모 2의 코드를 까보니 hash만 하는 것 같기도)
암호화를 해서 Set-Cookie로 브라우저에 저장하고, HTTP 요청 시 전달받은 쿠키의 sessionId는 다시 복호화해서 userId에 접근할 수 있어야 겠다.

그래서 AES 암복호화 모듈을 우선 구현해줬다. 학습메모 3,4,5를 참고하여 함호화 및 복호화 함수를 구현했다.

// utils/AesCrypto.js
import crypto from "crypto";
import { aesConfig } from "./AesCrypto.config.js";

const encryptAes = (plainText) => {
    const algorithm = "aes-256-cbc"; // 암호 알고리즘
    const key = crypto.scryptSync(aesConfig.password, aesConfig.salt, 32); // 암호화 키
    const iv = Buffer.alloc(16, 0); // 초기화 벡터

    const cipher = crypto.createCipheriv(algorithm, key, iv);
    let cipherText = cipher.update(plainText, "utf8", "base64");
    cipherText += cipher.final("base64");
    return cipherText;
};

const decryptAes = (cipherText) => {
    const algorithm = "aes-256-cbc"; // 암호 알고리즘
    const key = crypto.scryptSync(aesConfig.password, aesConfig.salt, 32); // 암호화 키
    const iv = Buffer.alloc(16, 0); // 초기화 벡터

    const decipher = crypto.createDecipheriv(algorithm, key, iv);
    let plainText = decipher.update(cipherText, "base64", "utf8");
    plainText += decipher.final("utf8");
    return plainText;
};

export { encryptAes, decryptAes };

처음엔 학습메모 3을 참고해서 const iv = crypto.randomBytes(16);로 iv를 만들었는데, 자꾸 값이 이상하게 나와서 보니 초기 16바이트 블록만 randomByte때문에 변조가 된거더라..
지금 생각해보니 encrypt, decrypt에서 동일한 iv를 사용해야 하는데, 그러지 않아서 그렇다. AES-256-CBC 알고리즘 대학원에서 다 배운건데 코드로 보니 또 새롭다. 다 까먹은듯

저렇게 0으로 채워진 IV는 아무 의미가 없으니 RandomBytes를 사용하는 방식으로 다시 개선해보자.

import crypto from "crypto";
import { aesConfig } from "./AesCrypto.config.js";

const algorithm = "aes-256-cbc"; // 암호 알고리즘
const key = crypto.scryptSync(aesConfig.password, aesConfig.salt, 32); // 암호화 키
const iv = crypto.randomBytes(16); // 초기화 벡터

const encryptAes = (plainText) => {
    const cipher = crypto.createCipheriv(algorithm, key, iv);
    let cipherText = cipher.update(plainText, "utf8", "base64");
    cipherText += cipher.final("base64");
    return cipherText;
};

const decryptAes = (cipherText) => {
    const decipher = crypto.createDecipheriv(algorithm, key, iv);
    let plainText = decipher.update(cipherText, "base64", "utf8");
    plainText += decipher.final("utf8");
    return plainText;
};

export { encryptAes, decryptAes };

encrypt, decrypt시 password와 salt는 숨겨야 하므로 별도의 파일로 만들어 보호해줬다. .gitignore에도 추가!

export const aesConfig = {
    password: "myPassword",
    salt: "mySalt"
};
Object.freeze(aesConfig);

테스트 코드도 같이 작성해서 문제 없는지 확인해보았다.

import { encryptAes, decryptAes } from "../../utils/AesCrypto";

describe("encryptAes(), decryptAes() 테스트", () => {
  test("임의의 텍스트를 encryptAes() 후 decryptAes()하면 원본 텍스트로 돌아와야 함", () => {
    const plainText = "abcde테스트테스트";
    const cipherText = encryptAes(plainText);
    console.log(cipherText);
    expect(decryptAes(cipherText)).toEqual(plainText);
  });
  test("임의의 텍스트를 encryptAes() 후 decryptAes()하면 원본 텍스트로 돌아와야 함", () => {
    const plainText = "hello World!!?";
    const cipherText = encryptAes(plainText);
    console.log(cipherText);
    expect(decryptAes(cipherText)).toEqual(plainText);
  });
  test("임의의 텍스트를 encryptAes() 후 decryptAes()하면 원본 텍스트로 돌아와야 함", () => {
    const plainText = "왜 안되는 걸까? 계속 하다보면 뭔가잘 되려나? 그 이후로 되는게 뭔가 찜찜한데 내 예상이 맞다면 이 뒤로는 잘?";
    const cipherText = encryptAes(plainText);
    console.log(cipherText);
    expect(decryptAes(cipherText)).toEqual(plainText);
  });
  test("임의의 텍스트를 encryptAes() 후 decryptAes()하면 원본 텍스트로 돌아와야 함", () => {
    const plainText = "Why isn't it working? {} jjj{  } \" djfiej \" ddjdjdj??1!@#% ";
    const cipherText = encryptAes(plainText);
    console.log(cipherText);
    expect(decryptAes(cipherText)).toEqual(plainText);
  });
});

스크린샷 2023-10-11 오후 2 04 11

문제없이 잘 통과되었다. JSON 형식을 암호화 시킬 예정이므로 {, ", }이 잘 들어가는지도 확인해줬다.

먼저 HttpRequest 파싱 시 쿠키를 처리하여 별도로 #cookie 속성에 저장하는 로직을 구현해보자.

스크린샷 2023-10-11 오후 12 08 05

크롬의 개발자도구 Application 탭을 활용해 테스트를 위한 3개의 쿠키를 생성해준다.


생각해보니 HTTP 헤더는 대소문자를 구분하지 않는 것이 표준이므로, key-value 저장 시
우선 소문자로 변경해주는 로직을 추가했다.

this.#headerObj = {};
this.#headerList.forEach(header => {
    const [ key, value ] = header.split(": ");
    this.#headerObj[key.toLowerCase()] = value; // HTTP 스펙에 맞게 대소문자 구분 없게(소문자로 통일)
});

...

// 쿠키 있으면 파싱
if (this.#headerObj['cookie']) {
    this.#parseCookie();
}

header 구성 이후 cookie라는 헤더가 있으면, 그 값을 이용해 쿠키를 파싱하는 parseCookie() 메소드로 넘어가준다.


스크린샷 2023-10-11 오후 4 31 44

parseCookie() 메소드는 위와 같이 구성된 쿠키 헤더의 Value를 ; , =
파싱해주는 함수이다.

#parseCookie() {
    const cookieList = this.#headerObj['cookie'].split("; ");

    this.#cookieObj = {};
    cookieList.forEach(cookie => {
        const [ key, value ] = cookie.split("=");
        this.#cookieObj[key] = value;
    });

    // 세션 있으면 파싱
    if(Object.keys(this.#cookieObj).includes('sessionId')) {
        this.#parseSession();
    }
}

만약 이렇게 구성된 cookie에 sessionId가 있다면 다시 세션을 파싱해줘야 한다.

session 구현 1 (request 받기)

sessionId가 있으면 AES 복호화 및 JSON Parse를 해야 한다.

#parseSession() {
    const sessionId = this.#cookieObj['sessionId'];
    this.#sessionObj = JSON.parse(decryptAes(sessionId));
}

잘 되는지 확인을 하려면 우선 암호화된 값을 얻어야 하는데, iv가 실행 시마다 다르게
설정해둬서 그게 어렵다. 그래서 우선 다시 iv를 Buffer.alloc(16, 0)으로 변경.


test("Object로 주어진 세션 데이터를 암호화해 base64형태의 sessionId로 변환할 수 있어야 함", () => {
  const sessionObj = {
    userId: "myUserId"
  };
  const plainText = JSON.stringify(sessionObj);
  const cipherText = encryptAes(plainText);
  console.log(cipherText);
  expect(decryptAes(cipherText)).toEqual(plainText);
});

AES 복호화 및 JSON Parse가 잘 작동하는지 확인하기 위해 테스트코드에서
userId를 담은 Object를 JSON Stringify 후 암호화해줬다.

스크린샷 2023-10-11 오후 4 35 56

이렇게 얻은 "eqbKsCu9xCd3+hjRRM3peMwKIXj1POB9mUGySvZmtEs="를
개발자 도구로 직접 넣어주고

스크린샷 2023-10-11 오후 4 25 21

서버 켜고 새로고침 한 번 해줘서 session이 잘 파싱되는지 보자.

스크린샷 2023-10-11 오후 4 39 56

복호화 잘 된다!

session 구현 2 (response 보내기)

response를 보내려면 지난 주에 마구잡이로 짰던 staticRouter의 Response 방식을 앞서 만든 Response 객체에서 메시지를 구성해서 보내는 형태로
추상화를 시켜줘야 한다. 해당 로직을 리팩토링하기 위해 많은 고민과 수정을 거쳤지만 아직 더 정리되어야 한다. 우선 한데까지만 보자!

static(statusCode, headerObj, messageBody) {
    this.#statusCode = statusCode;
    this.#resonPhrase = STATUS_CODE[this.#statusCode];
    const startLine = `${this.#version} ${this.#statusCode} ${this.#resonPhrase}`;

    this.#headerObj = headerObj;
    if (this.#cookie) { // 쿠키 등록이 필요하면
        this.#headerObj["Set-Cookie"] = [this.#cookie, "HttpOnly"].join("; ");
    }
    this.#headerList = [];
    Object.keys(this.#headerObj).forEach(key => {
        this.#headerList.push(`${key}: ${this.#headerObj[key]}`);
    });

    this.#messageHead = [startLine, ...this.#headerList].join('\r\n');
    this.#messageBody = messageBody;

    this.#response();
}

200 OK 응답코드 처리를 위해 ok() 메소드를 HttpResponse 객체에 만들어줬다가,
404 Not Found까지 동일한 형태로 처리해주기 위해 static()으로 이름을 바꾸고 statusCode까지 인자로 받았다.

원래는 staticRouter에서 응답 HTTP 헤더를 string으로 각각 써서 보냈는데, 속성값들을 이용해 헤더 string을 객체에서 만들도록 했다.
앞으로 messageHead 합치는 작업도 별도 메소드로 빼주면 될 것 같다.

set session(sessionObj) {
  const sessionId = encryptAes(JSON.stringify(sessionObj)); // AES 암호화

  const cookieObj = {sessionId};
  this.#cookie = Object.keys(cookieObj).map(key => `${key}=${cookieObj[key]}`).join("; "); // 쿠키 string 만들기
}

세션 등록은 res.session = {userId: "myUserId"};와 같이 바로 값을 넣을 수 있도록 setter로 구성해줬다.
실제론 sessionId라는 이름의 쿠키를 등록해줘야 하므로 Set-Cookie를 하기 위한 쿠키 value를 만들어주고 cookie속성에 저장한다.

if (this.#cookie) { // 쿠키 등록이 필요하면
    this.#headerObj["Set-Cookie"] = [this.#cookie, "HttpOnly"].join("; ");
}

앞선 static()코드에 있는 이 부분에서 header에 저장된 this.#cookie를 활용해 Set-Cookie를 추가해준다. HttpOnly 옵션도 줘봤다.


테스트 겸 리팩토링을 위해 staticRouter.js와 app.js에서 데이터를 주고받는 인터페이스를 바꿨다.

// staticRouter.js
const postResMessage = (contentType, fullPath) => {
  if (!fs.existsSync(fullPath)) {
    const data = fs.readFileSync(`${__dirname}/../views/error.html`).toString();

    const statusCode = "400";
    const headerObj = {
      'Content-Type': 'text/html',
    };
    const messageBody = data;
    parentPort.postMessage({statusCode, headerObj, messageBody});
  } else {
    if (["image/ico", "image/png", "image/jpg"].includes(contentType)) {
      // 이미지는 Content-Length를 헤더에 포함해 Buffer로 전송
      const data = fs.readFileSync(fullPath);
      const compressedData = zlib.gzipSync(data);

      const statusCode = "200";
      const headerObj = {
        'Content-Type': contentType,
        'Content-Length': compressedData.length,
        'Content-Encoding': 'gzip',
      };
      const messageBody = compressedData;
      parentPort.postMessage({statusCode, headerObj, messageBody});
    } else {
      const data = fs.readFileSync(fullPath).toString();
      
      const statusCode = "200";
      const headerObj = {
        'Content-Type': contentType,
      };
      const messageBody = data;
      parentPort.postMessage({statusCode, headerObj, messageBody});
    }
  }
};

staticRouter에서는 statusCode, headerObj, messageBody를 생성해 반환하고,

// app.js
const staticWorker = new Worker("./routes/staticRouter.js");
staticWorker.on("message", (data) => {
  const { statusCode, headerObj, messageBody } = data;
  const res = new HttpResponse(socket);
  res.static(statusCode, headerObj, messageBody);

  ...
});

app.js에서는 이걸 이용해서 실제 response를 해준다.


이제 드디어 테스트를 위해! res에 세션까지 등록해서 응답 및 요청 테스트를 해보았다.

// app.js
const staticWorker = new Worker("./routes/staticRouter.js");
staticWorker.on("message", (data) => {
  const { statusCode, headerObj, messageBody } = data;
  const res = new HttpResponse(socket);
  res.session = {
    userId: "pjha999@naver.com",
    testCode: "이것은 테스트 입니다."
  };
  res.static(statusCode, headerObj, messageBody);

  ...
});
스크린샷 2023-10-11 오후 5 38 37 스크린샷 2023-10-11 오후 5 47 24

우선 200 OK, 404 Not Found 요청은 기존처럼 잘 처리된다.

스크린샷 2023-10-11 오후 5 53 10

이제 오늘의 핵심인 Set-Cookie 처리. sessionId가 암호화되어 잘 등록되어 있다.
잘 보면 위의 404, 200 코드에도 Set-Cookie가 응답 헤더에 있는 것이 보인다.

스크린샷 2023-10-11 오후 5 54 54

Request 파싱에서 아까 만든 parseCookie, parseSession이 잘 동작하는지도 확인해보자.
서버 로그에서 우리가 작성했던 테스트 세션 Object가 잘 복구된 것이 보인다. 야호!

리팩토링: HttpRequest 객체 사용하여 요청 처리

스크린샷 2023-10-11 오후 6 32 24 스크린샷 2023-10-11 오후 6 32 47

학습메모 6을 참고하여 정확한 명칭으로 이름을 바꾸고, HttpRequestParser 객체의
startLineObj 속성에 path, query를 나누어 추가하도록 변경해보자.

#parseUrl() {
    const requestTarget = this.#startLineObj['requestTarget'];
    this.#queryObj = parseQueryString(getQueryString(requestTarget));
}
parse() {
    // emptyLine (CRLF)로 body와 body가 아닌 부분 먼저 split
    [ this.#messageHead, this.#messageBody ] = this.#request.split("\r\n\r\n"); 
    [ this.#startLine, ...this.#headerList ] = this.#messageHead.split("\r\n");

    const [ method, requestTarget, version ] = this.#startLine.split(" ");
    this.#startLineObj = {method, requestTarget, version};

    // 쿼리 파라미터 있으면 파싱
    this.#startLineObj['path'] = getPath(requestTarget);
    if (isQueryString(requestTarget)) {
        this.#parseUrl();
    }

    ...
}

다음으로 모든 요청에 대해 HttpRequestParser를 거치도록 app.js를 변경했다.

socket.on("data", (data) => {
  const request = data.toString();
  const req = new HttpRequestParser(request);
  const { method, path } = req.startLineObj;
  const [filename, ext] = path.split(".");
  const queryObj = req.queryObj;

  logger.info(`${method} ${req.startLineObj.requestTarget}`);

  if (path === "/create") {
    ...
  }
  else if (path === "/login") {
    ...
  }
  else if (path && EXT_LIST.includes(ext)) {
    ...
  }
});

로그인/로그아웃 상태에 따른 header 표시 변경

먼저 login 여부에 따라 동적으로 Nav영역 구성이 변하도록 만들어보자.

<!-- index.html -->
<div class="nav">
  <div class="title">HELLO, WEB!</div>
  <button class="signBtn">로그인/회원가입</button>
  <div class="loggedIn" style="display: none;">
    <div class="memberListBtn">멤버리스트</div>
    <button class="myPageBtn">마이페이지</button>
    <button class="logoutBtn">로그아웃</button>
  </div>
</div>

index.html에 loggedIn영역을 멤버리스트, 마이페이지, 로그아웃 버튼 포함해 추가하고,

// header.js
const setLoginStatus = async () => {
    const header = new Header();

    const isLoggedIn = await getLoginStatus();

    if (isLoggedIn) {
        header.btnLogin.style.display = "none";
        header.divLoggedIn.style.display = "flex";
    } else {
        header.btnLogin.style.display = "flex";
        header.divLoggedIn.style.display = "none";
    }
};

const onWindowLoad = () => {
    setLoginStatus();
    setEvtsOnNav();
};

header.js에 getLoginStatus()를 통해 받은 로그인 상태에 따라 display:none,
display:flex를 스위칭한다.

// fetch.js
const getLoginStatus = async () => {
    const url = "/login/status";
    const res = await fetch(url);
    const { isLoggedIn } = await res.json();

    console.log(isLoggedIn);

    return isLoggedIn;
};

export { getLoginStatus };

getLoginStatus()는 fetch.js에 따로 분리했으며, /login/status 경로로 get 요청을 한다.
이제 서버 단에서 해당 요청을 처리해주자.

// app.js
else if (path === "/login/status") {
  const loginStatus = isLoggedIn(req);

  const res = new HttpResponse(socket);
  res.json({isLoggedIn: loginStatus});
}

먼저 app.js에서 해당 경로에 대한 조건문을 달아주고, isLoggedIn 함수로 로그인 여부를 판단해서
json으로 응답해준다. 해당 로직을 완성하기 위해 두 가지 추가 구현이 필요하다.

  1. isLoggedIn(req) : 로그인 여부 판단 함수
  2. res.json(obj) : json으로 응답하는 메소드

// loginController.js
const isLoggedIn = (req) => {
    return req.session.userId?true:false;
};

req에서 session 파싱해서 get/set하는 건 이미 완성해뒀으므로, 위와 같이 userId가 있는지만
판단해주면 된다.

스크린샷 2023-10-11 오후 7 44 56

console.log로 잘 가져오는 것 확인.

// HttpResponse.js
json(obj) {
    const data = JSON.stringify(obj);

    this.#statusCode = "200";
    this.#resonPhrase = STATUS_CODE[this.#statusCode];
    const startLine = `${this.#version} ${this.#statusCode} ${this.#resonPhrase}`;

    this.#headerObj = {
        'Content-Type': 'application/json',
        'Content-Length': data.length,
    };
    this.#headerList = [];
    Object.keys(this.#headerObj).forEach(key => {
        this.#headerList.push(`${key}: ${this.#headerObj[key]}`);
    });

    this.#messageHead = [startLine, ...this.#headerList].join('\r\n');
    this.#messageBody = data;

    this.#response();
}

다음으론 HttpResponse 객체의 json() 메소드를 만들어줬다.
Content-Type은 application/json, Content-Length도 추가해주고, 200 OK로 보내면 된다.


드디어 최종 확인!

스크린샷 2023-10-11 오후 8 35 48

console로 찍어보면 true값도 잘 받아오고, 이에 따라 Nav바도 변경되는 것을 확인할 수 있다.

스크린샷 2023-10-11 오후 8 39 18

쿠키를 없애고 새로고침하면 로그인 버튼으로 돌아온다.

회원 로그인 기능 구현

로그인

오늘은 DB없이 로그인 처리만 구현한다. 그러니 스프린트1의 isValidAccount 및 DB조회 부분은 빼고 무조건 로그인하는 걸로.

// app.js
else if (path === "/login") {
  const loginRouter = new Worker("./routes/loginRouter.js");
  ...
  loginRouter.postMessage(request); // request 전달
}

먼저 /login에 대한 요청이 오면 loginRouter로 request를 넘겨준다.

스크린샷 2023-10-11 오후 10 36 18

여기서 req(HttpRequestParser 객체)를 넘겨주려고 여러번 시도해봤는데 postMessage로 안넘겨지더라.
string같은 거 말고 정의된 클래스는 넘길 수가 없나보다.

// loginRouter.js
parentPort.on("message", (request) => {
    console.log(request);
    const req = new HttpRequestParser(request);
    const { method, path } = req.startLineObj;

    let result, url;
    if(method === "POST" && path === "/login") {
        result = doLogin(req);
        
        if (result.success) {
            url = '/index.html';
        } else {
            url = '/login_failed.html';
        }
    } else {
        url = '/login_failed.html';
    }
    parentPort.postMessage({result, url});
});

loginRouter에서는 POST /login 시 loginController의 doLogin() 실행.
여기서는 HttpRequestParser로 잘 넘어간다. 같은 쓰레드 안이니까!

// loginController.js
const doLogin = (req) => {
    if(req.session?.userId) {
        console.log("이미 로그인 된 상태입니다.");
        return {success: false};
    } else {
        // TODO: Account 검증 로직 추가. isValidAccount()
        return {success: true, userId: req.messageBodyObj.email};
    }
};

여기서는 session.userId가 있는지만 검사하고, 없으면 로그인하도록 값을 넘겨준다.
내일 Account 검증 로직 추가해야 함. isValidAccount().

// app.js
else if (path === "/login") {
  const loginRouter = new Worker("./routes/loginRouter.js");
  
  loginRouter.on("message", data => {
    const { result, url } = data;
    const res = new HttpResponse(socket);

    if (result.success) {
      const userId = result.userId;
      res.session = {userId};
    }
    res.redirect(url);
  });
  loginRouter.on("exit", () => {
    console.log("Worker(Thread) is done!");
  });

  loginRouter.postMessage(request); // request 전달
}

결과는 loginRouter에서 다시 app.js로 넘겨주니 app.js에서 이걸 처리해서 redirect 응답을 보내줘야 한다.
redirect에 아까 만들었던 Set-Cookie 넣는 로직도 추가해줬다.


이제 테스트.

스크린샷 2023-10-11 오후 11 12 06

초기 페이지

스크린샷 2023-10-11 오후 11 12 39

로그인 시도

스크린샷 2023-10-11 오후 11 12 59

정상적으로 로그인되어 index.html로 리다이렉트됨


로그아웃

로그아웃 기능은 logoutBtn에 이벤트를 등록하는것부터 시작한다.

// header.js
class Header {
    ...
    #btnLogout;
    ...
    get btnLogout() {
        return this.#btnLogout;
    }
    load() {
        ...
        this.#btnLogout = this.#divLoggedIn.querySelector(".logoutBtn");
    }
};

const setEvtsOnNav = () => {
    const header = new Header();
    ...
    header.btnLogout.addEventListener("click", e => {
        document.location.href = '/logout';
    });
};

로그아웃 버튼 속성에 추가하고 클릭이벤트로 /logout GET 하도록 등록.

// app.js
else if (path === "/logout") {
  const res = new HttpResponse(socket);

  const url = "/index.html";
  res.session = {};
  res.redirect(url);
}

app.js에 로그아웃 요청 등록. session을 빈 객체로 만들어 sessionId를 덮어써준다. 쿠키는 만료시키지 않고!


아까 화면에서 로그아웃 버튼 클릭하면

스크린샷 2023-10-11 오후 11 19 22

정상적으로 로그아웃 처리됨. 개발자도구 Network 탭에서 로그를 확인해보면 Set-Cookie가 정상적으로 되는 것을 확인할 수 있다.
sessionId를 덮어쓰는 건데 로그인/회원가입 버튼이 보이는거니 userId 속성이 잘 지워진 것! 굳

학습 메모

  1. toLowerCase()
  2. express-session
  3. crypto 모듈로 aes암복호화
  4. crypto 모듈
  5. crypto 모듈로 aes암복호화2(영문)
  6. 모든 개발자를 위한 HTTP 웹 기본 지식
profile
해커 출신 개발자

0개의 댓글