로그인 정보 세션 구현
쿠키로 로그인 정보를 저장하고 인증 미들웨어에서는 쿠키에서 유저정보를 가져와 DB에서 복호화한 토큰으로 유저 정보를 찾아 인증상태를 넘겨줬다.
쿠키의 단점
- 가장 큰 단점은 보안에 취약하다는 점이다.
- 요청 시 쿠키의 값을 그대로 보내기 때문에 유출 및 조작 당할 위험이 존재한다.
- 쿠키에는 용량 제한이 있어 많은 정보를 담을 수 없다.
- 웹 브라우저마다 쿠키에 대한 지원 형태가 다르기 때문에 브라우저간 공유가 불가능하다.
- 쿠키의 사이즈가 커질수록 네트워크에 부하가 심해진다.
쿠키는 일단 보안에 약하고 프론트 단에서 탈취가 가능하다.
예를들어 관리자 계정으로 요청/응답 처리할 때 탈취되면 쉽게 보안이 뚫릴 수 있다.
다만 서버가 아닌 브라우저에 저장이 되기 때문에 서버자원을 덕 먹는다.
결국 세션방식도 구현할 줄 알아야 한다고 판단
npm i express-session
일단 세션만 구현해봤다. 서버에서 세션 옵션을 설정하고 session을 사용하도록 한다.
// server.js
const sessionOption = {
name: 'SENTENCEU_SESSION',
secret: process.env.COOKIE_SECRET, // 쿠키를 임의로 변조하는 것을 방지하는 값
resave: false, // 세션에 변경사항이 없어도 저장할 지
saveUninitialized: false, // 세션이 최초 생성되고 수정이 안되면 uninitialized 상태로 변경해서 저장(보통 false)
cookie: {
httpOnly: true, // 브라우저에서 쿠키 조회하지 못하도록(프론트)
},
};
app.use(session(sessionOption));
기존 로그인 방식은 토큰 생성 후에 토큰을 쿠키에 저장하고 끝이었다.
세션을 구현한 후에는 로그인 과정에 세션에도 유저정보와 토큰을 저장하는 코드를 작성해준다.
/**
* @path {POST} /api/users/login
* @description 로그인
*/
router.post('/users/login', (req, res, next) => {
try {
// 1. DB에서 유저명으로 데이터 찾기
User.findOne({ userName: req.body.userName }, (error, user) => {
if (!user) return res.status(404).send('존재하지 않는 유저명입니다.'); // 유저명이 존재하지 않을 경우
// 2. DB에서 비밀번호가 맞는지 확인 (comparePassword)
user.comparePassword(req.body.password, (error, isMatch) => {
if (!isMatch) return res.status(404).send('비밀번호가 틀렸습니다.'); // 비밀번호 틀렸을 경우
// 3. 토큰 생성 (generateToken)
user.generateToken((error, user) => {
if (error) return res.status(400).send(error);
// 4. 세션 생성 << 기존엔 없던 내용
req.session.user = {
id: user._id,
token: user.token,
name: user.userName,
isAuth: true,
};
// 5. 토큰을 쿠키에 저장
// maxAge:: 쿠키 수명(밀리초), httpOnly:: 쿠키 접근 웹서버만
return res
.cookie('SENTENCEU_COOKIE', user.token, { maxAge: null, httpOnly: true })
.status(200)
.send('Login Succeeded');
});
});
});
} catch (error) {
next(error);
}
});
로그아웃은 간단하다. 토큰과 쿠키의 내용을 지우듯 세션도 destroy함수를 통해 지워준다.
/**
* @path {GET} /api/users/logout
* @description 로그아웃
*/
router.get('/users/logout', auth, (req, res) => {
// req.user는 auth에서 받아온 request
// DB에서 userId로 찾아 token을 ''로 업데이트
User.findOneAndUpdate({ _id: req.user._id }, { token: '' }, (error, user) => {
if (error) return res.status(404).send(error);
req.session.destroy(); // 세션 삭제
return res.status(200).clearCookie('SENTENCEU_COOKIE').send('Logout Succeeded'); // 로그아웃 시 쿠키 삭제
});
});
하지만 지금까지의 방법은 그냥 세션을 구현하고 브라우저단에서 저장해놓는 방식이다.
보안적인 이슈 때문에, 세션은 비밀번호 등 클라이언트의 민감한 인증 정보를 브라우저가 아닌 서버 측에 저장하고 관리한다.
서버의 메모리에 저장하기도 하고, 서버의 로컬 파일이나 데이터베이스에 저장하기도 한다.
현재 센텐스유는 MongoDB를 사용하고 있어서 connect-mongodb-session 라이브러리를 통해 MongoDB에 세션을 저장하기로 했다.
npm i connect-mongodb-session
const session = require('express-session');
const MongoDBStore = require('connect-mongodb-session')(session);
const sessionOption = {
name: 'SENTENCEU_SESSION',
secret: process.env.COOKIE_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
},
// store 옵션을 추가해 MongoDBStore에 세션을 저장해주도록 한다.
store: new MongoDBStore({
uri: process.env.MONGO_URI, // MongoDB URI
collection: 'sessions', // 세션을 저장할 컬렉션 이름
}),
};
app.use(session(sessionOption));
로그인에 성공 하면 MongoDB에 sessions 컬렉션에 세션이 저장된 모습을 볼 수 있다. 로그아웃하면 삭제된다.
PWA 설정
PWA라는 자체를 실험적으로 테스트해보고 싶어서 CRA생성 시 PWA템플릿으로 만들어진 코드를 가져다가 센텐스유에 적용해봤다.
매니페스트, 서비스워커 파일들이 잘 동작하고 PWA가 성공적으로 추가됐다.
문제는 여기서부터
PWA를 쉽게 삭제하고 초기화하고 그럴 수 없었다.
이미 서비스는 임시배포를 한 상태였고 PWA를 적용한 후에 접속했던 유저들은 PWA가 적용된 서비스를 사용하고 있는 것이다.
PWA의 서비스워커가 파일들을 캐시스토리지에 캐싱해놓은 상태였고, 새로운 버전의 서비스워커를 찾지 못한 이상 자동으로 업데이트 되지 못한다.
(유저들이 강제로 캐시들을 삭제해야하는데 그럴 수는 없지 않은가)
PWA코드를 처음부터 작성해보자
- 매니패스트 파일(manifest.json)
: 다양한 방법으로 작성할 수 있다.
PWABuilder 에서 본인의 사이트 링크를 넣으면 PWA에 대한 점수가 나오고 매니페스트 파일 생성도 가능하다. - 서비스워커 파일(service-worker.js) ⭐️
: PWA의 꽃이라고 볼 수 있다. 캐싱, 버전, 푸시, 업데이트 등 모든 것을 관리한다.
구글 크롬개발자의 서비스워커 공식문서를 보고 차례대로 작성해 나아갔다.
센텐스유의 서비스워커는 아래와 같은 방법으로 추가했다.
client.jsx(index.jsx)
- 윈도우가 로드되면 서비스 워커(SW)에 대한 명령어들이 실행된다.
WorkBox사용, SW 등록, SW 버전확인, 새로운 버전의 SW가 있을 경우 프롬프트 실행 등
// client.jsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
/*===================================================
Work Box 사용
===================================================*/
const wb = new Workbox('/sw.js');
wb.addEventListener('installed', (event) => {
if (!event.isUpdate) {
console.log('Workbox가 설치 되었습니다.');
}
});
wb.register();
/*===================================================
SW 등록
===================================================*/
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW 등록 성공::', registration);
const convertedVapidPublicKey = urlBase64ToUint8Array(process.env.WEBPUSH_PUBLIC_KEY);
registration?.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidPublicKey,
});
Notification.requestPermission().then((p) => {
console.log(p);
});
})
.catch((error) => {
console.log('SW 등록 실패:: ', error);
});
/*===================================================
SW 버전 확인
===================================================*/
let swVersion;
const getVersion = wb.messageSW({ type: 'GET_VERSION' });
getVersion.then((res) => {
console.log(`Service Worker Version:: ${res}`);
swVersion = res;
});
/*===================================================
새로운 버전의 SW 업데이트 프롬프트
===================================================*/
const showSkipWaitingPrompt = (event) => {
// 유저가 업데이트를 수락할 경우, 대기 중이던 SW가 제어권을 얻음.
wb.addEventListener('controlling', () => {
// 이 시점에서 다시 로드하면 현재 탭이 새 SW의 제어 하에 로드됨.
window.location.reload();
});
// 업데이트 수락 할 프롬프트
Swal.fire({
title: `${swVersion} 버전 업데이트 안내`,
text: '새로운 버전이 있습니다.',
icon: 'info',
confirmButtonColor: '#008bf8',
confirmButtonText: '업데이트',
}).then((result) => {
// 업데이트 수락 시
if (result.isConfirmed) {
wb.messageSkipWaiting();
}
});
};
/*===================================================
새로 등록 된 SW가 설치되었지만 활성화 대기 중인 시점을 탐지
===================================================*/
wb.addEventListener('waiting', (event) => {
showSkipWaitingPrompt(event);
});
});
}
service-worker.js
- 서비스 워커가 하는 역할의 코드들을 작성한다.
파일 캐싱, 캐시 삭제, SW업데이트 등
// service-worker.js
/*===================================================
SW 버전 관리
===================================================*/
const SW_VERSION = '1.0.0';
addEventListener('message', (event) => {
if (event.data.type === 'GET_VERSION') {
event.ports[0].postMessage(SW_VERSION);
}
});
/*===================================================
사전 캐싱 없이 Workbox 사용
===================================================*/
const navigationRoute = new NavigationRoute(
new NetworkFirst({
cacheName: 'navigations',
}),
);
const imageAssetRoute = new Route(
({ request }) => {
return request.destination === 'image';
},
new CacheFirst({
cacheName: 'image-assets',
}),
);
// 라우터 등록:
registerRoute(navigationRoute);
registerRoute(imageAssetRoute);
/*===================================================
오프라인 캐싱 및 SW 설치
===================================================*/
const FALLBACK_CACHE_NAME = 'offline-fallback';
const FALLBACK_HTML = '/offline.html';
const networkWithFallbackStrategy = new NetworkOnly({
// 오프라인이거나 네트워크 응답이 있기 전에
// 5초 이상 경과한 경우 캐시된 offline.html로 폴백
networkTimeoutSeconds: 5,
plugins: [
{
handlerDidError: async () => {
return await caches.match(FALLBACK_HTML, {
cacheName: FALLBACK_CACHE_NAME,
});
},
},
],
});
self.addEventListener('install', (event) => {
console.log('Installed:: SW 설치 완료');
event.waitUntil(caches.open(FALLBACK_CACHE_NAME).then((cache) => cache.add(FALLBACK_HTML)));
});
// 모든 탐색을 처리할 경로를 등록
registerRoute(new NavigationRoute(networkWithFallbackStrategy));
/*===================================================
SW 활성화 후 기존 캐시 삭제
===================================================*/
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
}),
);
console.log('Activated:: SW 캐시 삭제');
});
/*===================================================
여러 항목 캐싱
===================================================*/
// 이미지 캐싱:
const imageRoute = new Route(
({ request }) => {
return request.destination === 'image';
},
new StaleWhileRevalidate({
cacheName: 'images',
}),
);
// 스크립트 캐싱:
const scriptsRoute = new Route(
({ request }) => {
return request.destination === 'script';
},
new CacheFirst({
cacheName: 'scripts',
}),
);
// 스타일 캐싱:
const stylesRoute = new Route(
({ request }) => {
return request.destination === 'style';
},
new CacheFirst({
cacheName: 'styles',
}),
);
// 라우트 등록
registerRoute(imageRoute);
registerRoute(scriptsRoute);
registerRoute(stylesRoute);
/*===================================================
새로운 버전의 SW 업데이트 수락 시
===================================================*/
// Evict image cache entries older thirty days:
const imageExpRoute = new Route(
({ request }) => {
return request.destination === 'image';
},
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
// 30일 이상 지난 이미지 캐시 항목 제거
maxAgeSeconds: 60 * 60 * 24 * 30,
}),
],
}),
);
const scriptsExpRoute = new Route(
({ request }) => {
return request.destination === 'script';
},
new CacheFirst({
cacheName: 'scripts',
plugins: [
new ExpirationPlugin({
// 캐시에 50개 이상의 항목이 있는 경우
// 가장 적게 사용되는 스크립트 캐시 항목을 제거
maxEntries: 50,
}),
],
}),
);
// Register routes
registerRoute(imageExpRoute);
registerRoute(scriptsExpRoute);
/*===================================================
새로운 버전의 SW 업데이트 수락 시
===================================================*/
addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
webpack.config.js
- SW 빌드에 필요한 환경 설정을 추가해준다.
//webpack.config.js
entry: {
sw: './src/service-worker', // Service Worker
},
plugins: [
new WebpackPwaManifest({
manifestcode...
}),
new WorkboxPlugin.InjectManifest({
swSrc: './src/service-worker.js',
swDest: 'service-worker.js',
}),
]
2~3일간 이 서비스워커에만 고생했더니 결국 성공적으로 결과가 나와서 다행이라고 생각했다.
신규 유저든, 기존 유저든 새로 배포하는 서비스를 잘 사용 할 수 있으니깐 말이다.
깃 브랜치를 잘 사용하자.. 너무 실험적인 것도 메인브랜치에서 작업하는 습관을 고치자..