[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를 사용할 수 있다는 점인데, 나는 그냥 다음 의식의 흐름으로 진행시켜버렸다.
- JWT Token이 우리 서비스의 인증 수단이기 때문에 평문이 노출되면 안된다.
- 현재 만들 수 있는 JWT Token은 14일짜리 refresh용 토큰과 1시간짜리 access 토큰뿐이다.
- 그렇담 payload에 userId가 들어있는 하루짜리 정도의 새로운 JWT Token을 만든다.
- payload는 암호화된 정보가 아니기 때문에 AES 암호화로 JWT Token 전체를 암호화한다...ㅎㅎ
- 이동한 페이지에서 복호화한 뒤 axios 헤더에 포함해 사용자 행위를 요청한다.
- 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¢erImageUrl=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=
- 지금 방식으로는 쌩으로 암호화 한 토큰 때문에 이렇게 길고 긴 링크가 만들어지는데 보기 좋지 않다...😅
- 위에서 말한 방법으로 이후 개선해보는걸로!
'엘리스 AI트랙 4기 > 프로젝트' 카테고리의 다른 글
[PWA] Push Notification 외전2. chrome에서의 subscription token 만료 문제 (feat. nodemailer) (0) | 2022.07.31 |
---|---|
[PWA] Push Notification 안드로이드 QR 코드로 구독하기 (0) | 2022.07.25 |
[PWA] Push Notification 알림 자동 발송하기 (feat. node-schedule) (1) | 2022.07.18 |
[PWA] Push Notification 상황별 다른 알림 옵션 설정하기 (0) | 2022.07.16 |
[PWA] Push Notification Nodejs 프로젝트에 적용하기 (0) | 2022.07.15 |