엘리스 AI트랙 4기/프로젝트

[PWA] Push Notification 외전1. query string으로 사용자 인증값 전달하기 (feat. AES 암호화)

남쪽마을밤송이 2022. 7. 27. 04:39

 [01 들어가기에 앞서 ] 

 (1) query string이란? 

  • 사용자가 입력 데이터를 전달하는 방법 중의 하나로서 URL 주소에 미리 협의된 데이터를 파라미터를 통해 넘기는 것이다.
  • 여기서 사용자 입력이란 사용자가 검색한 키워드가 될 수도 있고 임의로 설정한 필터 조건이 될 수도 있다.
  • 사용하는 방법은 URL과 조건문 사이의 ‘?’ 구분자를 두고 필요한 파라미터 값을 적는다. 그리고 파라미터가 여러개일 경우 ‘&’ 를 붙여 여러개의 파라미터를 넘길 수 있다.

 (2) 사용하게 된 배경 

  • push actions 클릭시 넘어가는 페이지(오늘의 체크리스트 작성 페이지)와 QR code 연결 페이지(모바일 구독 페이지) 이동 후에 별도의 로그인 없이 사용자 인증 후 요청을 할 수 있게 하고 싶었다.
  • 그런데 REST api처럼 body값을 넘길 수 있는 형태가 아니기 때문에 데이터 전송을 할 수 있는 방법은 query string 뿐이었다.
  • 따라서 QR code login 방식의 원리부터 찾아보았다. login과 체크인은 좀 다르겠지만 "네이버 QR 체크인에는 어떤 개인 정보가 담겨있을까?" 이런 글을 발견했다.
  • 결론적으로는 JWT Token의 payload에 사용자 정보를 담고, 사용자 정보는 (내부 알고리즘에 따라) 암호화되어 있었다. 그리고 QR 코드는 15초에 한 번씩 갱신되어 JWT Token을 새로 만드는 것 같았다.

 (3) 향후 보완할 점 

  • 처음부터 네이버 체크인과 비슷하게 구현했다면 좋을텐데, 이때의 나는 JWT Token을 사용하는 이유를 정확하게 이해하지 못하고 있었다. 그냥 Token과 JWT Token의 가장 큰 차이점이자 JWT의 장점은 payload를 사용할 수 있다는 점인데, 나는 그냥 다음 의식의 흐름으로 진행시켜버렸다.
    1. JWT Token이 우리 서비스의 인증 수단이기 때문에 평문이 노출되면 안된다.
    2. 현재 만들 수 있는 JWT Token은 14일짜리 refresh용 토큰과 1시간짜리 access 토큰뿐이다.
    3. 그렇담 payload에 userId가 들어있는 하루짜리 정도의 새로운 JWT Token을 만든다.
    4. payload는 암호화된 정보가 아니기 때문에 AES 암호화로 JWT Token 전체를 암호화한다...ㅎㅎ
    5. 이동한 페이지에서 복호화한 뒤 axios 헤더에 포함해 사용자 행위를 요청한다.
    6. middleware에서 verify로 토큰을 검증하고 payload에 들어있는 userId로 사용자를 구분한다.
  • 따라서 암호화 한 토큰을 parameter로 넣어 URL이 길어짐은 물론 QR코드도 담을 문자열이 많으니 엄청 복잡해졌다. 이 부분을 꼭 고치고 싶었는데 아마도 payload에 들어갈 userId를 암호화하고 JWT Token을 1분에 한 번 정도 갱신되도록 변경하면 될 것 같다.
  • 그런데 front에서도 1분마다 QR code를 갱신해야 하고 사용자에게 갱신까지 남은 시간을 보여줄 timer도 추가해야 하기 때문에 프로젝트 채점 기간인 지금은 못 수정하고 있다...
  • 이후에 개선하는 걸로 하고 지금도 동작은 잘 하기 때문에 현재 구현한 방식을 포스팅하겠다.

 

 [02 암호화 ] 

 (1) Subscribe Page Encrypt 

  • 간단하게 대칭형 양방향 암호화를 하는게 맞다고 생각해서 AES 알고리즘을 선택했다.
  • 검색해보니 npm의 crypto-js 패키지로 쉽게 구현 가능한 것 같아서 사용했다.
  • 중요한 점은 secretKey를 아무거나 설정하면 안되고 key generator를 이용해야 한다는 것이다. 나는 여기서 128bit짜리로 생성했다. key는 당연히 환경변수로 관리한다.
    • 처음에 내 마음대로 설정했다가 decrypted가 안돼서 처음에 뭐가 문제인지 모르고 헤맸다..ㅎ
  • 그리고 현재 로그인 한 사용자의 토큰을 AES Encrypt 하고 query string으로 설정한 경로를 encoding해서 QRcode 연결 경로로 설정해준다.
// subscribe.tsx
import { AES } from "crypto-js";

function Subscribe() {
  const secretKey: any = process.env.REACT_APP_SECRET_KEY;
  const jwtToken = String(sessionStorage.getItem("userToken"));
  const encryptedToken = AES.encrypt(jwtToken, secretKey).toString();
  const encodedPageLink = encodeURIComponent(`${process.env.REACT_APP_MODE}:${process.env.REACT_APP_FRONT_PORT}/m/subscribe?token=${encryptedToken}`);
  const QRcode = `https://quickchart.io/qr?text=${encodedPageLink}&ecLevel=L&size=200&centerImageUrl=https://ifh.cc/g/Y4Z5z3.png`;
  
  const subscribe = async () => {
    console.log("subscribe function");
    const sw = await navigator.serviceWorker.ready;
    // web 구독에서 사용할 구독과 구독 취소 함수...

 (2) Today's Checklist Page Encrypt 

  • 오늘의 체크리스트 작성 페이지로 이동 후 encrypt하는 부분이다.
  • Subscribe Page에서 했던 것과 비슷하고 sendNotifiation으로 service worker에게 전송한다.
import webpush from "web-push";
import { AES } from "crypto-js";
import { Subscribe } from "../db/Subscribe";
import { Schedule } from "../db/Schedule";
import { pushData } from "../interfaces/subscribeInput";
import { HttpException } from "../utils/error-util";
import { makeChecklistToken, makeResubscribeToken } from "../utils/jwt-util";
import { emailUtil } from "../utils/emailUtil";

const SubscribeService = {
  pushSupplementSchedules: async (time: Date) => {
    const supplementSchedulesDataArray = await Schedule.findByOnlyTime(time);
    supplementSchedulesDataArray.forEach(async (scheduleData: any) => {
      const supplementArray: string[] = [];
      for (const supplement of scheduleData.User.DailySupplements) {
        supplementArray.push(supplement.Supplement.name);
      }

      // Today's Checklist 페이지에 쓸 token(만료 기간 하루) 발급
      const checklistToken = makeChecklistToken({
        userId: scheduleData.User["pk_user_id"],
      });

      const pushData: pushData = {
        name: scheduleData.User["user_name"],
        when: scheduleData.to_do,
        supplements: supplementArray.join(", "),
        jwtToken: checklistToken,
      };

      const secretKey: any = process.env.SECRET_KEY;

      const notificationData = {
        messageType: "supplement",
        title: `${pushData.name}님, ${pushData.when} 영양제 드실 시간이에요!`,
        body: `${pushData.supplements} 영양제를 복용해주세요.`,
        encryptedToken: AES.encrypt(pushData.jwtToken, secretKey).toString(),
      };

      const subscriptionArray = scheduleData.User.Subscribes;

      for (const subscription of subscriptionArray) {
        try {
          await webpush.sendNotification(
            subscription.device_token,
            JSON.stringify(notificationData)
          );
        } catch (error) {
          // 사용자 토큰 만료되면 에러 발생
          if (error instanceof Error) {
            // DB에서 만료된 기기 정보 삭제 후 메일 전송하기
            await Subscribe.delete(subscription.device_token);

            const resubscribeToken = makeResubscribeToken({
              userId: scheduleData.User["pk_user_id"],
            });
            const emailData = {
              user_name: scheduleData.User["user_name"],
              email: scheduleData.User["email"],
              encryptedToken: AES.encrypt(
                resubscribeToken,
                secretKey
              ).toString(),
            };
            await emailUtil.expirationEmail(emailData);

            console.log(
              `사용자 ${scheduleData.User["pk_user_id"]}의 토큰 정보가 만료되어 삭제 후 재갱신 메일을 전송했습니다.`
            );
          }
        }
      }
    });

    return supplementSchedulesDataArray;
  },
};

export { SubscribeService };
  • options 중 data를 사용해서 암호화된 토큰을 notificationClick 이벤트로 전송할 수 있고 사용자가 액션 클릭시 그 토큰을 query string으로 설정한 경로로 이동하도록 했다.
// ServiceWorker.js
self.addEventListener("push", (event) => {
  const data = event.data.json();

  const supplementMessageOptions = {
    // ... 다른 옵션들 ...
    actions: [
      {
        action: "homepage-action",
        title: "Pill my rhythm",
      },
      {
        action: "checklist-action",
        title: "Today's Checklist",
      },
    ],
    // data로 action 실행 시 객체 전송 가능
    data: {
      encryptedToken: data.encryptedToken,
    },
  };

  switch (messageType) {
    case "info": {
      // 브라우저는 전달된 Promise가 확인될 때까지 서비스 워커를 활성화 및 실행 상태로 유지
      event.waitUntil(self.registration.showNotification(data.title, infoMessageOptions));
      break;
    }
    case "supplement": {
      event.waitUntil(self.registration.showNotification(data.title, supplementMessageOptions));
    }
    // no default
  }
});

self.addEventListener(
  "notificationclick",
  (event) => {
    // 알림창 닫음
    event.notification.close();
    switch (event.action) {
      case "homepage-action":
        // URL을 로드하는 새 창이나 탭이 열림
        event.waitUntil(self.clients.openWindow("http://localhost:3000"));
        break;
      case "checklist-action": // 오늘 날짜의 체크리스트
        const { encryptedToken } = event.notification.data;
        event.waitUntil(self.clients.openWindow(`http://localhost:3000/m/checklist?token=${encryptedToken}`));
        break;
      // no default
    }
  },
  false,
);

 

 [03 복호화 ] 

 (1) Subscribe Page Decrypt 

  • 모바일 구독 페이지로 이동 후 decrypt하는 과정이다.
  • 여기서 예상치 못하게 시간이 들었던 부분은 문자 "+"이 연산자 +로 해석되는지 토큰이 중간에 띄어쓰기(" ")로 분리되어 전송되길래 replaceAll로 모두 다시 "+"로 변경해주었다.
// mobile/Subscribe.tsx
import { AES, enc } from "crypto-js";

const Subscribe = () => {
  const queryString = new URLSearchParams(window.location.search);
  let encryptedToken: any = queryString.get("token");
  encryptedToken = encryptedToken.replaceAll(" ", "+");

  const secretKey: any = process.env.REACT_APP_SECRET_KEY;
  const decryptedToken = AES.decrypt(encryptedToken, secretKey);
  const jwtToken = decryptedToken.toString(enc.Utf8);

  const subscribe = async () => {
  const sw = await navigator.serviceWorker.ready;

 (2) Today's Checklist Page Decrypt 

  • Subscribe Page에서 했던 방식과 똑같다.
import { useState } from "react";
import { useRecoilValue } from "recoil";
import styled from "styled-components";
import { checkListAtom } from "../../../atoms";
import axios from "axios";
import { AES, enc } from "crypto-js";

const Checklist = () => {
  const queryString = new URLSearchParams(window.location.search);
  let encryptedToken: any = queryString.get("token");
  if (!encryptedToken) {
    console.error("토큰값이 전송되지 않았습니다.");
  }
  encryptedToken = encryptedToken.replaceAll(" ", "+");

  const secretKey: any = process.env.REACT_APP_SECRET_KEY;
  const decryptedToken = AES.decrypt(encryptedToken, secretKey);
  const jwtToken = decryptedToken.toString(enc.Utf8);

  const tasks = useRecoilValue(checkListAtom);
  const [checkedItems, setCheckedItems]: any = useState([]);
  // ...

 

 [04 결과 ] 

https://kdt-ai4-team17.elicecoding.com/m/subscribe?token=U2FsdGVkX180tkaq4YEId/DfWPER84udW3JXpFLLb2BykxntdJppuFWGFnVJ9LM7b/0Hl4v8mr32ddrhWbbWotqig1jkQSczpkcocZebryNwRkvQU3PJFFQUxNVJsWJfSUfnopIN1Kmzv5rKPLH8Qphqc7SQ8Tl7ab2BILj1WUPEnpvYafxXacq6bKmCTasbQ1gTYsZdqL5/oUC5g0fem/qT/nO2WH+LRZjqAaMzenmQw5Ek4Ea2WdztdPXtSajKgC3IyjSGWwIgid7ZIsna3htFi9Kb+LD7XTOXUJpaSbY=
  • 지금 방식으로는 쌩으로 암호화 한 토큰 때문에 이렇게 길고 긴 링크가 만들어지는데 보기 좋지 않다...😅
  • 위에서 말한 방법으로 이후 개선해보는걸로!