HTTP 전용 쿠키를 사용하여 XSS 공격으로부터 React 애플리케이션을 보호하는 방법
저자는 Write for DOnations 프로그램을 선택했습니다.
소개
토큰 기반 인증은 공용 자산과 개인 자산이 혼합된 웹 애플리케이션을 보호할 수 있습니다. 개인 자산에 액세스하려면 일반적으로 사용자만 알고 있는 사용자 이름과 비밀 암호를 제공하여 사용자가 자신을 성공적으로 인증해야 합니다. 성공적인 인증은 사용자가 인증된 상태를 유지하기로 결정한 기간 동안 토큰을 반환하므로 사용자는 권한 있는 자산에 액세스할 때마다 자신을 다시 인증할 필요 없이 토큰을 제공할 수 있습니다. 토큰 사용은 토큰을 안전하게 보관하기 위해 토큰을 어디에 보관해야 하는지에 대한 본질적인 질문을 제기합니다. 토큰은 XSS) 공격을 사용하여 브라우저 저장소에 저장될 수 있습니다. 로컬 및 세션 저장소의 콘텐츠는 데이터를 저장하는 동일한 문서에서 실행되는 모든 JavaScript에서 액세스할 수 있기 때문입니다.
이 자습서에서는 여러 플랫폼에서 일관된 테스트를 위해 로컬 Docker 컨테이너에 설정된 토큰 기반 인증 시스템을 구현하는 React 애플리케이션 및 모의 API를 만듭니다. Window.localStorage
속성과 함께 브라우저 저장소를 사용하여 토큰 기반 인증을 구현하는 것으로 시작합니다. 그런 다음 반사된 교차 사이트 스크립팅 공격으로 이 설정을 악용하여 브라우저 스토리지를 사용하여 비밀 정보를 유지할 때 존재하는 보안 취약성을 이해합니다. 그런 다음 문서에 있을 수 있는 잠재적으로 악의적인 JavaScript 코드에 더 이상 액세스할 수 없는 인증 토큰을 저장하는 HTTP 전용 쿠키로 변경하여 이 응용 프로그램을 개선합니다.
이 튜토리얼이 끝나면 React 및 Node 웹 애플리케이션과 함께 작동하는 토큰 기반 인증 시스템을 구현하는 데 필요한 보안 고려 사항을 이해하게 될 것입니다. 이 튜토리얼의 코드는 DigitalOcean 커뮤니티 GitHub에서 사용할 수 있습니다.
전제 조건
이 자습서를 완료하려면 다음이 필요합니다.
- Ubuntu 22.04에서 Docker를 설치하고 사용하는 방법 내부의 로컬 개발 환경입니다.\n
- 이 튜토리얼의 애플리케이션은
node:18.7.0-bullseye
를 실행하는 이미지를 기반으로 구축되었습니다. Node.js 설치 방법 및 로컬 개발 환경 만들기 시리즈를 설치할 수도 있습니다.
1단계 - 개발을 위한 Docker 컨테이너 준비
이 단계에서는 개발 목적으로 Docker 컨테이너를 설정합니다. 컨테이너를 생성하기 위해 이미지를 빌드하는 지침이 포함된 Dockerfile을 생성하여 시작합니다.
nano
또는 원하는 편집기를 사용하여 홈 디렉터리 내에Dockerfile
이라는 파일을 만들고 엽니다.- nano Dockerfile
내부에 다음 코드 줄을 배치합니다.
FROM node:18.7.0-bullseye RUN apt update -y \ && apt upgrade -y \ && apt install -y vim nano \ && mkdir /app WORKDIR /app CMD [ "tail", "-f", "/dev/null" ]
FROM
줄은 Dockerhub의 미리 빌드된node:18.7.0-bullseye
를 사용하여 이미지의 기반을 만듭니다. 이 이미지는 필수 NodeJS 종속성이 설치된 상태로 빌드되어 설정 프로세스를 간소화합니다.RUN
줄은 패키지를 업데이트하고 업그레이드하며 이 줄은 필요할 수 있는 다른 패키지도 설치합니다.WORKDIR
행은 작업 디렉토리를 설정합니다.CMD
줄은 컨테이너 내부에서 실행할 기본 프로세스를 정의하여 컨테이너가 계속 실행되어 개발에 연결하고 사용할 수 있도록 합니다.파일을 저장하고 닫습니다.
path_to_your_dockerfile
을 Dockerfile의 경로로 대체하여docker build
명령으로 Docker 이미지를 생성합니다.- docker build -f /path_to_your_dockerfile --tag jwt-tutorial-image .
Dockerfile의 경로는
-f
옵션으로 전달되어 이미지를 빌드할 파일 경로를 나타냅니다.--tag
옵션을 사용하여 이 빌드에 태그를 지정하면 나중에 독자에게 친숙한 이름(이 경우jwt-tutorial-image
)으로 참조할 수 있습니다. .build
명령을 실행하면 다음과 유사한 출력이 표시됩니다.Output... => => writing image sha256:1cf8f3253e430cba962a1d205d5c919eb61ad106e2933e33644e0bc4e2cdc433 0.0s => => naming to docker.io/library/jwt-tutorial-image다음 명령을 사용하여 이미지를 컨테이너로 실행합니다.
- docker run -d -p 3000:3000 -p 8080:8080 --name jwt-tutorial-container jwt-tutorial-image
-d
플래그는 별도의 터미널 세션으로 연결할 수 있도록 분리 모드에서 컨테이너를 실행합니다.참고: Docker 컨테이너를 실행하는 데 사용하는 것과 동일한 터미널을 사용하여 개발하려면
-d
플래그를-it
로 바꾸십시오. 컨테이너 내에서 실행되는 대화형 터미널.-p
플래그는 컨테이너의 포트3000
및8080
을 전달합니다. 이러한 포트는 로컬 브라우저를 사용하여 애플리케이션을 테스트할 수 있도록 호스트 시스템의localhost
네트워크에 프런트 엔드 및 백엔드 애플리케이션을 각각 제공합니다.참고: 호스트 시스템이 현재
3000
및8080
포트를 사용 중인 경우 해당 포트를 사용하는 애플리케이션을 중지해야 합니다. 포트.-P
플래그를 사용하여 컨테이너의 포트를 컴퓨터의localhost
네트워크에서 사용하지 않는 포트로 전달할 수도 있습니다. 특정 포트를 매핑하는 대신-P
플래그를 사용하는 경우docker network inspect your_container_name
을 실행하여 어떤 개발 컨테이너 포트가 있는지 확인해야 합니다. 어떤 로컬 포트에 매핑됩니다.원격 컨테이너 플러그인을 사용하여 VSCode와 연결할 수도 있습니다.
별도의 터미널 세션에서 다음 명령을 실행하여 컨테이너에 연결합니다.
- docker exec -it jwt-tutorial-container /bin/bash
연결되었음을 나타내기 위해 컨테이너 레이블과 함께 다음과 같은 연결이 표시됩니다.
Outputroot@d7e051c96368:/app#이 단계에서는 미리 빌드된 Docker 이미지를 설정하고 개발에 사용할 컨테이너에 연결합니다. 다음으로
create-react-app
를 사용하여 컨테이너에서 애플리케이션의 골격을 설정합니다.2단계 — 프런트 엔드 애플리케이션의 기초 설정
이 단계에서는 React 애플리케이션을 초기화하고
ecosystem.config.js
파일을 사용하여 앱 관리를 구성합니다.컨테이너에 연결한 후
mkdir
명령을 사용하여 애플리케이션용 디렉터리를 만든 다음cd
명령을 사용하여 새로 만든 디렉터리로 이동합니다.- mkdir /app/jwt-storage-tutorial
- cd /app/jwt-storage-tutorial
그런 다음
npx
명령을 사용하여create-react-app
바이너리를 실행하여 웹 애플리케이션의 프런트엔드 역할을 할 새 React 프로젝트를 초기화합니다.- npx create-react-app front-end
create-react-app
바이너리는 응용 프로그램 개발 및 테스트를 위한README
파일과 <react-scripts,react-dom
및jest
.설치를 계속할지 묻는 메시지가 표시되면
y
를 입력합니다.create-react-app
에 대한 호출의 다음 출력을 볼 수 있습니다.Output... Success! Created front-end at /home/nodejs/jwt-storage-tutorial/front-end Inside that directory, you can run several commands: yarn start Starts the development server. yarn build Bundles the app into static files for production. yarn test Starts the test runner. yarn eject Removes this tool and copies build dependencies, configuration files and scripts into the app directory. If you do this, you can’t go back! We suggest that you begin by typing: cd front-end yarn start Happy hacking!create-react-app
버전에 따라 결과가 약간 다를 수 있습니다.개발 인스턴스를 시작하고 새 React 애플리케이션 작업을 시작할 준비가 되었습니다.
응용 프로그램을 실행하려면 PM2 프로세스 관리자를 사용합니다. 다음 명령으로
pm2
를 설치합니다.- npm install pm2 -g
-g
플래그는 패키지를 전체적으로 설치합니다. 로그인한 사용자의 권한에 따라 패키지를 전체적으로 설치하려면sudo
명령을 사용해야 할 수도 있습니다.PM2는 응용 프로그램의 개발 및 생산 단계에서 여러 가지 이점을 제공합니다. 예를 들어 PM2는 개발 중에 애플리케이션의 다양한 구성 요소를 백그라운드에서 실행하도록 도와줍니다. 가동 중지 시간을 최소화하면서 프로덕션 응용 프로그램을 패치하기 위한 배포 모델 구현과 같은 프로덕션의 운영 요구 사항에 PM2를 사용할 수도 있습니다. 자세한 내용은 PM2: Production-Ready Nodejs Applications in Minutes를 참조하십시오.
설치 결과는 다음과 유사합니다.
Outputadded 183 packages, and audited 184 packages in 2m 12 packages are looking for funding run `npm fund` for details found 0 vulnerabilities -->PM2 프로세스 관리자를 사용하여 애플리케이션을 실행하려면 React 프로젝트 디렉토리로 이동하고
nano
또는 원하는 편집기를 사용하여ecosystem.config.js
라는 파일을 만듭니다.- cd front-end
- nano ecosystem.config.js
ecosystem.config.js
파일은 애플리케이션을 실행하는 방법에 대한 PM2 프로세스 관리자의 구성을 보유합니다.새로 만든
ecosystem.config.js
파일에 다음 코드를 추가합니다.module.exports = { apps: [ { name: 'front-end', cwd: '/app/jwt-storage-tutorial/front-end', script: 'npm', args: 'run start', env: { PORT: 3000 }, }, ], };
여기에서 PM2 프로세스 관리자를 사용하여 새 앱 구성을 정의합니다.
name
구성 매개변수를 사용하면 쉽게 식별할 수 있도록 PM2 프로세스 테이블에서 프로세스 이름을 선택할 수 있습니다.cwd
매개변수는 실행할 프로젝트의 루트 디렉토리를 설정합니다.script
및args
매개변수를 사용하면 프로그램 실행을 위한 명령줄 도구를 선택할 수 있습니다. 마지막으로env
매개변수를 사용하면 JSON 객체를 전달하여 애플리케이션에 필요한 환경 변수를 설정할 수 있습니다. 프런트 엔드 애플리케이션이 실행될 포트를 설정하는 단일 환경 변수인PORT
만 정의합니다.파일을 저장하고 종료합니다.
이 명령을 사용하여 PM2 관리자가 현재 실행 중인 프로세스를 확인하십시오.
- pm2 list
이 경우 현재 PM2에서 어떤 프로세스도 실행하고 있지 않으므로 다음과 같은 출력이 표시됩니다.
Output┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘명령을 실행 중이고 새로운 슬레이트를 위해 프로세스 관리자를 재설정해야 하는 경우 다음 명령을 실행하십시오.
- pm2 delete all
이제
ecosystem.config.js
파일에 지정된 구성으로 PM2 프로세스 관리자를 사용하여 애플리케이션을 시작합니다.- pm2 start ecosystem.config.js
터미널에 다음과 유사한 출력이 표시됩니다.
Output┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤ │ 0 │ front-end │ fork │ 0 │ online │ 0% │ 33.6mb │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘stop
및start
명령과restart
및startOrRestart
명령을 사용하여 PM2 프로세스의 활동을 제어할 수 있습니다.원하는 브라우저에서
http://localhost:3000
으로 이동하여 애플리케이션을 볼 수 있습니다. 기본 React 시작 페이지가 표시됩니다.마지막으로 클라이언트 측 라우팅을 위해
react-router
버전 5.2.0을 설치합니다.- npm install react-router-dom@5.2.0
설치가 완료되면 다음 메시지의 변형을 받게 됩니다.
Output... added 13 packages, and audited 1460 packages in 7s 205 packages are looking for funding run `npm fund` for details 6 high severity vulnerabilities To address all issues (including breaking changes), run: npm audit fix --force Run `npm audit` for details.이 단계에서는 Docker 컨테이너에서 React 애플리케이션의 골격을 설정합니다. 다음으로 나중에 XSS 공격에 대해 테스트하는 데 사용할 응용 프로그램의 페이지를 빌드합니다.
3단계 - 로그인 페이지 구축
이 단계에서는 애플리케이션의 로그인 페이지를 만듭니다. 구성 요소를 사용하여 개인 자산과 공용 자산이 모두 있는 애플리케이션을 나타냅니다. 그런 다음 사용자가 웹 사이트의 개인 자산에 액세스할 수 있는 권한을 얻기 위해 자신을 확인하는 로그인 페이지를 구현합니다. 이 단계가 끝나면 개인 및 공용 자산과 로그인 페이지가 혼합된 표준 애플리케이션의 골격을 갖게 됩니다.
먼저 홈 및 로그인 페이지를 만듭니다. 다음으로 로그인한 사용자만 볼 수 있는 비공개 페이지를 나타내는
SubscriberFeed
구성 요소를 만듭니다.시작하려면 애플리케이션의 모든 구성 요소를 저장할
components
디렉토리를 만듭니다.- mkdir src/components
그런 다음
SubscriberFeed.js
라는components
디렉토리 안에 새 파일을 만들고 엽니다.- nano src/components/SubscriberFeed.js
SubscriberFeed.js
파일 내부에 내부 구성 요소의 제목이 있는<h2>
태그와 함께 다음 줄을 추가합니다.import React from 'react'; export default () => { return( <h2>Subscriber Feed</h2> ); }
파일을 저장하고 닫습니다.
다음으로
App.js
파일 내에서SubscriberFeed
구성 요소를 가져와 사용자가 구성 요소에 액세스할 수 있도록 경로를 생성합니다. 프로젝트의src
디렉터리에 있는App.js
파일을 엽니다.- nano src/App.js
react-router-dom
에서BrowserRouter
,Switch
및Route
구성 요소를 가져오려면 다음 강조 표시된 줄을 추가하세요. 패키지:import logo from './logo.svg'; import './App.css'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; function App() { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React </a> </header> </div> ); } export default App;
이를 사용하여 웹 애플리케이션에서 라우팅을 설정합니다.
다음으로 강조 표시된 줄을 추가하여 방금 만든
SubscriberFeed
구성 요소를 가져옵니다.import logo from './logo.svg'; import './App.css'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; function App() { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React </a> </header> </div> ); } export default App;
이제 기본 애플리케이션과 웹 페이지의 경로를 만들 준비가 되었습니다.
여전히
src/App.js
에서 반환된 JSX 줄(return
키워드 뒤의 괄호 안에 포함된 모든 내용)을 제거하고 강조 표시된 줄로 바꿉니다.import logo from './logo.svg'; import './App.css'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; function App() { return( <div className="App"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> </div> ); } export default App;
div
태그에는 애플리케이션 이름이 포함된<h1>
태그를 포함하는App
의className
속성이 있습니다. .<h1>
태그 아래에Switch
구성 요소를 사용하여Route
구성 요소를 래핑하는BrowserRouter
구성 요소를 추가합니다.SubscriberFeed
구성요소를 포함합니다.import logo from './logo.svg'; import './App.css'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; function App() { return( <div className="App"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
이 새 줄을 사용하면 애플리케이션의 경로를 정의할 수 있습니다.
BrowserRouter
구성 요소는 정의된 경로를 포함합니다.Switch
구성 요소는 반환된 경로가 사용자가 탐색하는 경로와 일치하는 첫 번째 경로인지 확인하고Route
구성 요소는 특정 경로 이름을 정의합니다.마지막으로 CSS를 사용하여 응용 프로그램에 패딩을 추가하여 제목과 구성 요소가 중앙에 배치되고 표시 가능하도록 합니다. 가장 바깥쪽
<div>
태그의className
속성에wrapper
를 추가합니다.import logo from './logo.svg'; import './App.css'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; function App() { return( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
App.js
파일을 저장하고 닫습니다.App.css
파일을 엽니다.- nano src/App.css
이 파일에 기존 CSS가 표시됩니다. 파일의 모든 항목을 삭제합니다.
그런 다음 다음 줄을 추가하여
wrapper
스타일 지정을 정의합니다..wrapper { padding: 20px; text-align: center; }
wrapper
클래스의text-align
속성을center
로 설정하여 애플리케이션에서 텍스트를 중앙에 배치합니다. 또한패딩
속성을20px
로 설정하여 래퍼 클래스에 20픽셀의 패딩을 추가했습니다.App.css
파일을 저장하고 닫습니다.새로운 스타일로 업데이트된 React 홈페이지를 볼 수 있습니다. 현재 표시되는 구독자 피드를 보려면
http://localhost:3000/subscriber-feed
로 이동합니다.경로는 예상대로 작동하지만 모든 방문자가 구독자 피드에 액세스할 수 있습니다. 구독자 피드가 인증된 사용자에게만 표시되도록 하려면 사용자가 자신의 사용자 이름과 비밀번호로 자신을 확인할 수 있는 로그인 페이지를 만들어야 합니다.
구성 요소 디렉터리에서 새
Login.js
파일을 엽니다.- nano src/components/Login.js
새 파일에 다음 줄을 추가합니다.
import React from 'react'; export default () => { return( <div className='login-wrapper'> <h1>Login</h1> <form> <label> <p>Username</p> <input type="text" /> </label> <label> <p>Password</p> <input type="password" /> </label> <div> <button type="submit">Submit</button> </div> </form> </div> ); }
<h1>
태그 헤더, 두 개의 입력(사용자 이름
및암호
) 및제출
로 양식을 만듭니다. 버튼.login-wrapper
의className
을 사용하여<div>
태그로 양식을 래핑하여App에서 스타일을 지정할 수 있습니다. css
파일입니다.파일을 저장하고 닫습니다.
프로젝트의 루트 디렉터리에서
App.css
파일을 열어Login
구성 요소의 스타일을 지정합니다.- nano src/App.css
다음 CSS 줄을 추가하여
login-wrapper
클래스의 스타일을 지정합니다.... .login-wrapper { display: flex; flex-direction: column; align-items: center; }
flex
의display
속성과center
의align-items
속성을 사용하여 구성 요소를 페이지 중앙에 배치합니다. 그런 다음flex-direction
을column
으로 설정하면 열에서 요소가 수직으로 정렬됩니다.파일을 저장하고 닫습니다.
마지막으로
useState
후크를 사용하여App.js
내부에Login
구성 요소를 렌더링하여 토큰을 메모리에 저장합니다.App.js
파일을 엽니다.- nano src/App.js
강조 표시된 줄을 파일에 추가합니다.
import logo from './logo.svg'; import './App.css'; import { useState } from 'react' import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; import Login from './components/Login'; function App() { const [token, setToken] = useState(); if (!token) { return <Login setToken={setToken} /> } return( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
먼저
react
패키지에서useState
후크를 가져옵니다.또한 새
token
상태 변수를 생성하여 로그인 프로세스 중에 가져올 토큰 정보를 저장합니다. 5단계에서는 브라우저 저장소를 사용하여 인증 상태를 유지함으로써 이 설정을 개선합니다. 7단계에서는 인증 상태를 안전하게 저장하기 위해 HTTP 전용 쿠키를 사용하여 지속성 방법을 더욱 강화합니다.또한
토큰
의 값이falsy
인 경우 로그인 페이지를 표시하는로그인
구성 요소를 가져옵니다.if
문은 토큰이falsy
인 경우 사용자가 인증되지 않은 경우 로그인해야 한다고 선언합니다.setToken
함수를Login
구성 요소에 소품으로 전달합니다.파일을 저장하고 닫습니다.
그런 다음 애플리케이션 페이지를 새로 고쳐서 새로 구축된 로그인 페이지를 로드합니다. 현재 토큰 설정을 위해 구현된 기능이 없기 때문에 애플리케이션은 로그인 페이지만 표시합니다.
이 단계에서는 인증되지 않은 사용자가 로그인할 때까지 보호되는 로그인 페이지 및 비공개 구성 요소로 애플리케이션을 업데이트했습니다.
다음 단계에서는 프런트엔드 애플리케이션에서 인증 토큰을 호출하기 위해 NodeJS와 새
로그인
경로를 사용하여 새 백엔드 애플리케이션을 만듭니다.4단계 — 토큰 API 생성
이 단계에서는 이전 단계에서 설정한 프런트 엔드 React 애플리케이션의 백엔드로 노드 서버를 생성합니다. 노드 서버를 사용하여 성공적인 프런트엔드 사용자 인증 시 인증 토큰을 반환하는 API를 만들고 사용 가능하게 합니다. 이 단계가 끝나면 애플리케이션에 작동하는 로그인 페이지, 성공적인 인증 후에만 사용할 수 있는 개인 리소스, API 호출을 통한 인증을 허용하는 백엔드 서버 애플리케이션이 생깁니다.
모든 경로에 대해 원본 간 리소스 공유를 사용하여 서버를 구축합니다. 그러면 CORS 오류 없이 애플리케이션을 테스트하고 개발할 수 있습니다.
경고: CORS는 교육 목적으로 개발 환경에서 활성화됩니다. 그러나 프로덕션 애플리케이션의 모든 경로에 대해 CORS를 활성화하면 보안 취약성이 발생합니다.
노드 프로젝트를 저장할
back-end
라는 새 디렉터리를 만들고 이동합니다.- mkdir /app/jwt-storage-tutorial/back-end
- cd /app/jwt-storage-tutorial/back-end
새 디렉터리에서 노드 프로젝트를 초기화합니다.
- npm init -y
init
명령은npm
명령줄 유틸리티에 명령이 실행되는 디렉터리에 새 노드 프로젝트를 생성하도록 지시합니다.-y
플래그는 대화형 명령줄 도구가 새 프로젝트를 만들 때 묻는 모든 초기화 질문에 대해 기본값을 사용합니다. 다음은-y
플래그와 함께 실행되는init
명령의 출력입니다.OutputWrote to /home/nodejs/jwt-storage-tutorial/back-end/package.json: { "name": "back-end", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }다음으로
back-end
프로젝트 디렉터리에express
및cors
모듈을 설치합니다.- npm install express cors
다음 출력의 일부 변형이 터미널에 나타납니다.
Outputadded 59 packages, and audited 60 packages in 3s 7 packages are looking for funding run `npm fund` for details found 0 vulnerabilities새
index.js
파일을 만듭니다.- nano index.js
다음 줄을 추가하여
express()
를 호출하고 결과를app<라는 이름으로 변수에 저장하여
express
모듈을 가져오고 새 Express 애플리케이션을 초기화합니다. /코드>:const express = require('express'); const app = express();
다음으로 강조 표시된 줄을 사용하여 미들웨어로 앱에
cors
를 추가합니다.const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors());
cors
모듈을 가져온 다음use
메서드를 사용하여app
개체에 추가합니다.그런 다음 강조 표시된 줄을 추가하여 로그인을 시도하는 사용자에게 토큰을 반환하는
/login
경로에 대한 핸들러를 정의합니다.const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors()); app.use('/login', (req, res) => { res.send({ token: "This is a secret token" }); });
app.use()
메서드를 사용하여 경로에 대한 요청 핸들러를 정의합니다. 이 경로를 사용하면 방금 빌드한 프런트 엔드 애플리케이션에서 인증 중인 사용자의 사용자 이름과 암호를 보낼 수 있습니다. 그 대가로 사용자가 백엔드 애플리케이션에 인증된 호출을 할 수 있도록 인증 토큰을 제공합니다.app.use
메서드의 첫 번째 인수는 애플리케이션이 요청을 수락할 경로입니다. 두 번째 인수는 애플리케이션이 받은 요청을 처리하는 방법을 자세히 설명하는 콜백입니다. 콜백은 요청 데이터를 포함하는req
인수와 응답 데이터를 포함하는res
인수의 두 가지 인수를 사용합니다.참고: 사용자가 백엔드 API를 사용하여 로그인을 요청할 때 전달된 자격 증명의 정확성을 확인하지 않습니다. 이 단계는 간결함을 위해 포함되지 않았지만 프로덕션 응용 프로그램은 일반적으로 인증 토큰을 발급하기 전에 올바른 사용자 이름과 암호를 제공했는지 확인하기 위해 데이터베이스에 사용자 정보를 쿼리합니다.
마지막으로 강조 표시된 줄을 추가하여
app.listen
함수를 사용하여 포트8080
에서 서버를 실행합니다.const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors()); app.use('/login', (req, res) => { res.send({ token: "This is a secret token" }); }); app.listen(8080, () => console.log(`API is active on http://localhost:8080`));
파일을 저장하고 닫습니다.
PM2로 백엔드 앱을 실행하려면 새
backend/ecosystem.config.js
파일을 만듭니다.- nano ecosystem.config.js
새로 만든
back-end/ecosystem.config.js
파일에 다음 구성 코드를 추가합니다.module.exports = { apps: [ { name: 'back-end', cwd: '/app/jwt-storage-tutorial/back-end', script: 'node', args: 'index.js', watch: ['index.js'] }, ], };
PM2는 프런트엔드 애플리케이션과 유사한 구성 매개변수를 사용하여 백엔드 애플리케이션을 관리합니다.
구성 파일에서
watch
매개변수를 설정하여 파일이 변경될 때마다 애플리케이션이 자동으로 다시 로드되도록 합니다.watch
매개변수는 코드가 변경되면 브라우저에서 결과를 업데이트하므로 유용한 개발 기능입니다. 기본적으로 자동 다시 로드 기능이 있는react-scripts
로 실행했기 때문에 프런트 엔드 애플리케이션에 대한 감시 매개변수가 필요하지 않았습니다. 그러나 백엔드 애플리케이션은 기본 기능이 없는노드
런타임을 사용하여 실행됩니다.파일을 저장하고 닫습니다.
이제
pm2
로 백엔드 애플리케이션을 실행할 수 있습니다.- pm2 start ecosystem.config.js
출력 결과는 다음과 같습니다.
Output[PM2][WARN] Applications back-end not running, starting... [PM2] App [back-end] launched (1 instances) ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤ │ 2 │ back-end │ fork │ 0 │ online │ 0% │ 24.0mb │ │ 0 │ front-end │ fork │ 9 │ online │ 0% │ 47.2mb │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘curl
을 사용하여 새로 만든 API 엔드포인트가 인증 토큰을 제대로 반환하는지 평가합니다.- curl localhost:8080/login
다음 출력이 표시되어야 합니다.
Output{"token":"This is a secret token"}이제 서버 로그인 경로가 예상대로 토큰을 반환한다는 것을 알고 있습니다.
다음으로 API를 사용하도록 프런트 엔드
Login
구성 요소를 수정합니다. 적절한front-end
폴더로 이동합니다.- cd ..
- cd front-end/src/components/
프런트 엔드
Login.js
파일을 엽니다.- nano Login.js
강조 표시된 줄을 추가합니다.
import React, { useRef } from 'react'; export default () => { const emailRef = useRef(); const passwordRef = useRef(); return( <div className='login-wrapper'> <h1>Login</h1> <form> <label> <p>Username</p> <input type="text" ref={emailRef} /> </label> <label> <p>Password</p> <input type="password" ref={passwordRef} /> </label> <div> <button type="submit">Submit</button> </div> </form> </div> ); }
이메일 및 비밀번호 입력 필드의 값을 추적하기 위해
useRef
후크를 추가합니다.useRef
후크에 바인딩된 입력 필드에 입력할 때 입력된 값은 참조에서 업데이트된 다음 제출 버튼을 누를 때 백엔드로 전송됩니다.다음으로 강조 표시된 줄을 추가하여 양식에서 제출 버튼을 눌렀을 때 처리할
handleSubmit
콜백을 만듭니다.import React, { useRef } from 'react'; async function loginUser(credentials) { return fetch('http://localhost:8080/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }).then(data => data.json()) } export default ({ setToken }) => { const emailRef = useRef(); const passwordRef = useRef(); const handleSubmit = async (e) => { e.preventDefault(); const token = await loginUser({ username: emailRef.current.value, password: passwordRef.current.value }) setToken(token) } return( <div className='login-wrapper'> <h1>Login</h1> <form onSubmit={handleSubmit}> <label> <p>Username</p> <input type="text" ref={emailRef} /> </label> <label> <p>Password</p> <input type="password" ref={passwordRef} /> </label> <div> <button type="submit">Submit</button> </div> </form> </div> ); }
handleSubmit
핸들러 함수 내에서loginUser
도우미 함수를 호출하여 이전에 만든 API의login
경로에 가져오기 요청을 합니다.handleSubmit
함수에 전달된 이벤트에서preventDefault
함수를 호출하면 제출 버튼의 기본 새로고침 기능이 실행되지 않으므로 앱이 대신 로그인 엔드포인트 및 핸들을 호출할 수 있습니다. 사용자 로그인에 필요한 단계. 또한Login
구성 요소에 소품으로 전달된 setter를 사용하여token
상태 변수의 값을 설정합니다.완료되면 파일을 저장하고 닫습니다.
브라우저에서 웹 애플리케이션을 확인할 때 이제 임의의 사용자 이름과 비밀번호로 로그인할 수 있습니다. 제출 버튼을 누르면 로그인한 페이지로 리디렉션됩니다. 페이지를 새로고침하면 React 앱이 토큰을 잃어버리고 로그아웃됩니다.
다음 단계에서는 브라우저 저장소를 사용하여 프런트 엔드 애플리케이션에서 받은 토큰을 유지합니다.
5단계 - 브라우저 저장소에 토큰 저장
사용자가 브라우저 세션과 페이지 새로 고침에 걸쳐 로그인 상태를 유지할 수 있다면 사용자 경험에 도움이 됩니다. 이 단계에서는
Window.localStorage
속성을 사용하여 사용자가 브라우저를 닫거나 웹 페이지를 새로 고칠 때 손실되지 않는 영구 사용자 세션에 대한 인증 토큰을 저장합니다. 최신 웹 앱에 대한 진행 중인 사용자 세션은 사용자가 동일한 웹 사이트에 대해 로그인 자격 증명을 지속적으로 사용할 필요가 없기 때문에 앱에서 처리하는 네트워크 트래픽을 줄입니다.브라우저 스토리지에는 다르지만 유사한 두 가지 유형의 스토리지인 세션 스토리지가 포함됩니다. 즉, 세션 저장소는 탭 세션 전체에서 데이터를 유지하는 반면 로컬 저장소는 탭 및 브라우저 세션 전체에서 데이터를 유지합니다. 브라우저 저장소에 토큰을 저장하려면 로컬 저장소를 사용합니다.
프런트 엔드 애플리케이션용
App.js
파일을 엽니다.- nano /app/jwt-storage-tutorial/front-end/src/App.js
브라우저 저장소 통합을 시작하려면 두 개의 도우미 함수(
setToken
및getToken
)를 정의하는 강조 표시된 줄을 추가하고token
변수를 변경합니다. 새로 구현된 기능을 사용하여 토큰을 얻으려면:import logo from './logo.svg'; import './App.css'; import { useState } from 'react' import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; import Login from './components/Login'; function setToken(userToken) { localStorage.setItem('token', JSON.stringify(userToken)); window.location.reload(false) } function getToken() { const tokenString = localStorage.getItem('token'); const userToken = JSON.parse(tokenString); return userToken?.token } function App() { let token = getToken() if (!token) { return <Login setToken={setToken} /> } return( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
두 가지 도우미 함수
setToken
및getToken
을 만듭니다.setToken
내에서localStorage
의setItem
함수를 사용하여 도우미 함수의userToken
입력 매개변수에서 매핑합니다.토큰
이라는 키에. 또한window.location
속성의reload
기능을 사용하여 페이지를 새로 고쳐 애플리케이션이 브라우저 저장소에서 새로 설정된 토큰을 찾고 애플리케이션을 다시 렌더링할 수 있도록 합니다.getToken
내에서localStorage
의getItem
함수를 사용하여token
키에 대한 값이 있는지 확인합니다. , 당신이 반환합니다.App()
함수 내에서 정의된 변수를 교체하여getToken
함수를 사용합니다.사용자가 웹사이트를 방문할 때마다 프런트엔드는 브라우저 저장소에 인증 토큰이 있는지 확인하고 로그인을 요청하는 대신 이미 존재하는 토큰을 사용하여 사용자를 확인하려고 시도합니다.
파일을 저장하고 닫은 다음 응용 프로그램을 새로 고칩니다. 이제 애플리케이션에 로그인하고 웹 페이지를 새로 고칠 수 있으며 다시 로그인할 필요가 없습니다.
이 단계에서는 브라우저 저장소를 사용하여 토큰 지속성을 구현했습니다. 다음 섹션에서 브라우저 저장소를 사용하여 토큰 기반 인증 시스템을 활용합니다.
6단계 - XSS 공격으로 브라우저 저장소 악용
이 단계에서는 현재 애플리케이션에서 단계별 교차 사이트 스크립팅 공격(XSS 공격이라고도 함)을 수행하여 브라우저 스토리지를 사용하여 비밀 정보를 유지할 때 존재하는 보안 취약점을 보여줍니다. 공격은 URL 링크의 형태로 이루어지며 클릭하면 피해자를 웹 애플리케이션으로 안내하고 애플리케이션에 제작된 코드를 주입합니다. 인젝션은 사용자를 속여 상호 작용하도록 하여 악의적인 에이전트가 피해자의 브라우저에 있는 로컬 저장소의 콘텐츠를 훔칠 수 있도록 합니다.
XSS 공격은 오늘날 가장 흔한 사이버 공격 중 하나입니다. 공격자는 일반적으로 신뢰할 수 있는 환경에서 코드를 실행하기 위해 악성 스크립트를 브라우저에 삽입합니다. 공격자는 종종 피싱 기술을 사용하여 스팸 이메일을 통해 전달되는 링크와 같이 악의적으로 제작된 링크와 상호 작용하여 사용자를 속여 브라우저 저장소의 콘텐츠를 손상시키도록 합니다.
XSS 공격은 도메인의 브라우저 스토리지가 도메인과 연결된 모든 문서에서 실행되는 JavaScript 코드에 완전히 액세스할 수 있기 때문에 순진한 피해자의 브라우저 스토리지 콘텐츠를 훔치려는 공격자에게 특히 중요합니다. 공격자가 특정 웹 문서에 대해 사용자 브라우저에서 JavaScript 코드를 실행할 수 있는 경우 해당 문서 사용자 브라우저와 연결된 웹 도메인에 대한 사용자 브라우저 저장소(로컬 및 세션 모두)의 콘텐츠를 훔칠 수 있습니다.
설명을 위해 URL 쿼리 매개변수를 통해 코드를 삽입할 수 있는
XSSHelper
라는 구성 요소를 만들어 응용 프로그램을 의도적으로 XSS 공격에 취약한 상태로 둡니다. 그런 다음 악성 URL을 만들어 이 취약점을 악용합니다. 악성 URL은 사용자가 브라우저에서 URL로 이동하여 웹 페이지에 삽입된 의심스러운 링크를 클릭할 때 로그인한 사용자의 로컬 저장소에 액세스하여 콘텐츠를 노출합니다.프런트 엔드 애플리케이션의
components
디렉터리에서XSSHelper.js
라는 새 구성 요소를 엽니다.- nano /app/jwt-storage-tutorial/front-end/src/components/XSSHelper.js
새 파일에 다음 코드를 추가합니다.
import React from 'react'; import { useLocation } from 'react-router-dom'; export default (props) => { const search = useLocation().search; const code = new URLSearchParams(search).get('code'); return( <h2>XSS Helper Active</h2> ); }
useLocation
후크를 가져오고useLocation
의search
속성을 통해code
쿼리 매개변수에 액세스하는 새 기능 구성 요소를 만듭니다. 코드> 후크.XSSHelper
구성 요소가 활성화되었음을 알리는 메시지와 함께<h2>
태그를 반환합니다.URLSearchParams
JavaScript 함수는 검색 문자열과 상호 작용하기 위한 getter와 같은 도우미 메서드를 제공합니다.이제 가져올 강조 표시된 줄을 추가하고
useEffect
후크를 사용하여 쿼리 매개변수의 값을 기록합니다.import React, { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; export default (props) => { const search = useLocation().search; const code = new URLSearchParams(search).get('code'); useEffect(() => { console.log(code) }) return( <h2>XSS Helper Active</h2> ); }
파일을 저장하고 닫습니다.
다음으로, 사용자가 애플리케이션의
xss-helper
경로로 이동할 때 구성 요소를 반환하도록App.js
파일을 수정합니다.App.js
파일을 엽니다.- nano /app/jwt-storage-tutorial/front-end/src/App.js
강조 표시된 줄을 추가하여
XSSHelper
구성 요소를 경로로 가져오고 추가합니다.import logo from './logo.svg'; import './App.css'; import { useState } from 'react' import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; import Login from './components/Login'; import XSSHelper from './components/XSSHelper' function setToken(userToken) { localStorage.setItem('token', JSON.stringify(userToken)); window.location.reload(false) } function getToken() { const tokenString = localStorage.getItem('token'); const userToken = JSON.parse(tokenString); return userToken?.token } function App() { let token = getToken() if (!token) { return <Login setToken={setToken} /> } return( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> <Route path="/xss-helper"> <XSSHelper /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
파일을 저장하고 닫습니다.
브라우저에서
localhost:3000/xss-helper?code=inject code here
로 이동합니다. 애플리케이션에 로그인했는지 확인하십시오. 그렇지 않으면XSSHelper
구성 요소에 액세스할 수 없습니다.마우스 왼쪽 버튼을 클릭하고 검사를 누릅니다. 그런 다음 콘솔 섹션으로 이동합니다. 콘솔 로그에
여기에 코드 삽입
이 표시됩니다.이제 URL 쿼리 매개변수를 구성 요소에 전달할 수 있다는 것을 알고 있습니다.
다음으로
dangerouslySetInnerHTML
속성을 사용하여 웹 페이지의 문서에서 구성요소로 전달되는 쿼리 매개변수의 값을 설정합니다. 이 구성요소는code
URL 쿼리 매개변수의 값을 가져와서 웹페이지의div
구성요소에 삽입합니다.경고: 프로덕션 환경에서
dangerouslySetInnerHTML
속성을 사용하면 애플리케이션이 XSS 공격에 취약해질 수 있습니다.XSSHelper
파일을 다시 엽니다.- nano XSSHelper.js
강조 표시된 줄을 추가합니다.
import React, {useEffect} from 'react'; import { useLocation } from 'react-router-dom'; export default (props) => { const search = useLocation().search; const code = new URLSearchParams(search).get('code'); useEffect(() => { console.log(code) }) return( <> <h2>XSS Helper Active</h2> <div dangerouslySetInnerHTML={{__html: code}} /> </> ); }
빈 JSX 태그(
<> ... >
)에서 반환되는 요소를 래핑하여 React 조각으로 작업할 때 구문상 불법인 다중 조각 JSX 반환을 방지합니다.파일을 저장하고 닫습니다.
이제 악의적으로 제작된 코드를 구성 요소에 삽입하여 웹 페이지에서 코드를 실행할 수 있습니다.
xss-helper
경로로 전송된code
쿼리 매개변수의 값이 애플리케이션의 문서에 직접 포함된다는 것을 알고 있습니다.href
속성을 사용하여 맞춤 자바스크립트 코드를 전달하는<a>
태그가 있는 링크로code
쿼리 매개변수의 값을 설정할 수 있습니다. 브라우저에 직접.브라우저에서 다음 URL로 이동합니다.
localhost:3000/xss-helper?code=<a href="javascript:alert(`You have been pwned`);">Click Me!</a>
위의 URL에서 웹페이지에
Click Me!
라는 링크로 표시되도록 쿼리 매개변수 XSS 페이로드를 만듭니다. 사용자가 링크를 클릭하면 링크는 브라우저에 제작된 JavaScript 코드를 실행하도록 지시합니다. 이 코드는alert
기능을 사용하여 You have been pwned라는 메시지가 포함된 팝업을 생성합니다.그런 다음 브라우저에서 다음 URL로 이동합니다.
localhost:3000/xss-helper?code=<a href="javascript:alert(`Your token object is ${localStorage.getItem('token')}. It has been sent to a malicious server >:)`);">Click Me!</a>
이 페이지의 경우 공격자는 JavaScript 코드로
localStorage
에 저장된토큰
의 값을 읽는 URL 쿼리 매개변수 스크립트 주입을 통해 브라우저 저장소 콘텐츠에 액세스할 수 있습니다.토큰이 존재하려면 애플리케이션에 로그인해야 악의적으로 제작된 URL이 로컬 스토리지에 저장된 토큰을 표시할 수 있습니다. Click Me! 링크를 클릭하면 토큰이 도난당했다는 팝업 메시지가 표시됩니다.
이 단계에서는 많은 샘플 공격 벡터 중 하나를 사용하여 코드를 실행했습니다. 의심하지 않는 사용자의 인증 토큰을 사용하여 악의적인 공격자는 웹 응용 프로그램에서 사용자를 가장하여 권한 있는 사이트 자산에 액세스할 수 있습니다. 이러한 테스트를 통해 인증 토큰과 같은 비밀 정보를 브라우저 스토리지에 저장하는 것이 안전하지 않다는 것을 알게 되었습니다.
다음으로 문서에서 실행되는 스크립트에 액세스할 수 없고 이러한 유형의 XSS 공격에 영향을 받지 않는 비밀 정보를 저장하는 대체 방법을 사용합니다.
7단계 - HTTP 전용 쿠키를 사용하여 브라우저 저장소 XSS 취약성 완화
이 단계에서는 HTTP 전용 쿠키를 사용하여 이전 단계에서 발견하고 악용한 XSS 취약점을 완화합니다.
HTTP 쿠키는 브라우저 내의 키-값 쌍에 저장된 정보 스니펫입니다. 추적, 개인화 또는 세션 관리에 자주 사용됩니다.
JavaScript는
Document.cookie
속성을 통해 HTTP 전용 쿠키에 액세스할 수 없습니다. 이는 악성 코드 삽입을 통해 사용자 정보를 도용하려는 XSS 공격을 방지하는 데 도움이 됩니다.Set-Cookie
헤더를 사용하여 인증된 클라이언트에 대해 서버측 쿠키를 설정할 수 있습니다. 이 쿠키는 클라이언트가 서버에 요청하는 모든 항목에서 사용할 수 있으며 서버에서 인증을 확인하는 데 사용할 수 있습니다. 사용자의 상태. Express와 함께cookie-parser
미들웨어를 사용하여 헤더를 설정하는 대신 이를 처리합니다.안전한 HTTP 전용 쿠키 기반 토큰 저장소를 구현하려면 다음 파일을 업데이트합니다.
- 백엔드
index.js
파일은 인증 성공 시 쿠키를 설정하도록로그인
경로를 구현하도록 수정됩니다. 백엔드에는 또한 두 개의 새로운 경로가 필요합니다. 하나는 사용자의 인증 상태를 확인하기 위한 것이고 다른 하나는 사용자를 로그아웃하기 위한 것입니다. - 백엔드의 새 경로를 사용하도록 프런트엔드
Login.js
및App.js
파일이 수정됩니다.
이러한 수정은 클라이언트 및 서버 코드에 대한 로그인, 로그아웃 및 인증 상태 기능을 구현합니다.
백엔드 디렉토리로 이동하고
cookie-parser
패키지를 설치하면 Express 앱에서 쿠키를 설정하고 읽을 수 있습니다.- cd /app/jwt-storage-tutorial/back-end
- npm install cookie-parser
다음 출력의 변형이 표시됩니다.
Output... added 2 packages, and audited 62 packages in 1s 7 packages are looking for funding run `npm fund` for details found 0 vulnerabilities...다음으로 백엔드 애플리케이션에서
index.js
를 엽니다.- nano /app/jwt-storage-tutorial/back-end/index.js
require
메서드를 사용하여 새로 설치된cookie-parser
패키지를 가져오도록 강조 표시된 코드를 추가하고 앱에서 미들웨어로 사용합니다.const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser') const app = express(); app.use(cors()); app.use(cookieParser()) app.post('/login', (req, res) => { res.send({ token: "This is a secret token" }); }); app.listen(8080, () => console.log('API active on http://localhost:8080'));
또한 개발 목적으로 CORS 제한을 우회하도록
cors
미들웨어를 구성합니다. 동일한 파일에서 강조 표시된 줄을 추가합니다.const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser') const app = express(); let corsOptions = { origin: 'http://localhost:3000', credentials: true, } app.use(cors(corsOptions)); app.use(cookieParser()) app.post('/login', (req, res) => { res.send({ token: "This is a secret token" }); }); app.listen(8080, () => console.log('API active on http://localhost:8080'));
corsOptions
개체 아래의origin
옵션을 사용하여Access-Control-Allow-Origin
CORS 헤더를 프런트엔드에서 도메인으로 설정합니다. API 요청을 보냅니다. 또한credentials
매개변수를true
로 설정하여 모든 API 요청에 대해 쿠키에 인증 토큰을 보내야 함을 프런트 엔드에 알립니다.origin
옵션의 값은 백엔드 처리를 위해 쿠키와 같은 액세스 제어 데이터를 허용할 도메인을 지정합니다.마지막으로
corsOptions
구성 개체를cors
미들웨어 개체에 전달합니다.다음으로
cookie-parser
미들웨어에 의해 경로 핸들러의 응답 객체에서 사용할 수 있는cookie()
메서드를 사용하여 사용자의 쿠키 토큰을 설정합니다.app.use(/login, (req, res)
섹션의 줄을 강조 표시된 줄로 바꿉니다.const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser') const app = express(); let corsOptions = { origin: 'http://localhost:3000', credentials: true, } app.use(cors(corsOptions)); app.use(cookieParser()) app.use('/login', (req, res) => { res.cookie("token", "this is a secret token", { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age, domain: "localhost", sameSite: 'Lax', }).send({ authenticated: true, message: "Authentication Successful."}); }); app.listen(8080, () => console.log('API active on http://localhost:8080'));
위의 코드 블록에서
token
키와이것은 비밀 토큰입니다
값으로 쿠키를 설정합니다.httpOnly
구성 옵션은 쿠키가 문서에서 실행 중인 JavaScript에 액세스할 수 없도록httpOnly
속성을 설정합니다.쿠키가 14일 후에 만료되도록
maxAge
속성을 설정합니다. 14일이 지나면 쿠키가 만료되고 브라우저에 새 인증 쿠키가 필요합니다. 따라서 사용자는 사용자 이름과 비밀번호를 사용하여 다시 로그인해야 합니다.sameSite
및domain
속성은 클라이언트 브라우저가 CORS 또는 기타 보안 프로토콜 문제로 인해 쿠키를 거부하지 않도록 설정됩니다.이제 로그인할 경로가 생겼으니 로그아웃할 경로가 필요합니다. 강조 표시된 줄을 추가하여 로그아웃 방법을 설정합니다.
const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser') const app = express(); let corsOptions = { origin: 'http://localhost:3000', credentials: true, } app.use(cors(corsOptions)); app.use(cookieParser()) app.use('/login', (req, res) => { res.cookie("token", "this is a secret token", { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age, domain: "localhost", sameSite: 'Lax', }).send({ authenticated: true, message: "Authentication Successful."}); }); app.use('/logout', (req, res) => { res.cookie("token", null, { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age, domain: "localhost", sameSite: 'Lax', }).send({ authenticated: false, message: "Logout Successful." }); }); app.listen(8080, () => console.log('API active on http://localhost:8080'));
logout
방법은login
경로와 유사합니다.logout
메소드는token
쿠키를null
로 설정하여 사용자가 쿠키로 저장한 토큰을 제거합니다. 그런 다음 사용자에게 성공적으로 로그아웃되었음을 알립니다.마지막으로 강조 표시된 줄을 추가하여 사용자 클라이언트가 사용자가 로그인되어 있고 개인 자산에 액세스할 수 있는지 여부를 확인할 수 있는
auth-status
경로를 구현합니다.const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser') const app = express(); let corsOptions = { origin: 'http://localhost:3000', credentials: true, } app.use(cors(corsOptions)); app.use(cookieParser()) app.use('/login', (req, res) => { res.cookie("token", "this is a secret token", { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age, domain: "localhost", sameSite: 'Lax', }).send({ authenticated: true, message: "Authentication Successful."}); }); app.use('/logout', (req, res) => { res.cookie("token", null, { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age, domain: "localhost", sameSite: 'Lax', }).send({ authenticated: false, message: "Logout Successful." }); }); app.use('/auth-status', (req, res) => { console.log(req.cookies) if (req.cookies?.token === "this is a secret token") { res.send({isAuthenticated: true}) } else { res.send({isAuthenticated: false}) } }) app.listen(8080, () => console.log('API active on http://localhost:8080'));
auth-status
경로는 사용자 인증 토큰의 예상 값과 일치하는token
쿠키를 확인합니다. 그런 다음 부울 값으로 응답하여 사용자가 인증되었는지 여부를 나타냅니다.완료되면 파일을 저장하고 닫습니다. 프런트엔드에서 백엔드 API를 통해 사용자의 인증 상태를 추적할 수 있도록 백엔드에서 필요한 사항을 변경했습니다.
다음으로 HTTP 전용 쿠키 기반 토큰 저장소를 구현하기 위해 필요한 프런트 엔드 변경을 수행합니다.
front-end
디렉터리로 이동하고Login.js
파일을 엽니다.- cd ..
- cd front-end/src/components/
- nano Login.js
강조 표시된 줄을 추가하여
Login
구성 요소에서loginUser
기능을 수정합니다.... async function loginUser(credentials) { return fetch('http://localhost:8080/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }).then(data => data.json()) } ...
가져오기 요청의
credentials
헤더를include
로 설정하면loginUser
함수가 API에서 쿠키로 설정할 수 있는 자격 증명을 보내도록 지시합니다. 백엔드에서 방금 수정한login
경로에 대한 호출.다음으로,
Login
구성 요소에 대한setToken
입력 속성을 제거하고handleSubmit
콜백 종료 시 사용을 유지하지 않을 것입니다. 더 이상 메모리의 토큰.또한
handlesubmit
함수 끝에서 새로 고침을 트리거해야 로그인 버튼을 클릭할 때 애플리케이션이 새로 고쳐지고 새로 설정된토큰
쿠키가 클라이언트에서 인식됩니다. 애플리케이션. 강조 표시된 줄을 추가합니다.... const handleSubmit = async (e) => { e.preventDefault(); const token = await loginUser({ username: emailRef.current.value, password: passwordRef.current.value }) window.location.reload(false); } ...
이제
Login.js
파일이 다음과 같아야 합니다.import React, { useRef } from 'react'; async function loginUser(credentials) { return fetch('http://localhost:8080/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }).then(data => data.json()) } export default () => { const emailRef = useRef(); const passwordRef = useRef(); const handleSubmit = async (e) => { e.preventDefault(); const token = await loginUser({ username: emailRef.current.value, password: passwordRef.current.value }) window.location.reload(false); } return( <div className='login-wrapper'> <h1>Login</h1> <form onSubmit={handleSubmit}> <label> <p>Username</p> <input type="text" ref={emailRef} /> </label> <label> <p>Password</p> <input type="password" ref={passwordRef} /> </label> <div> <button type="submit">Submit</button> </div> </form> </div> ); }
파일을 저장하고 닫습니다.
더 이상 인증 토큰을 메모리에 보관하지 않기 때문에 사용자가 로그인해야 하는지 또는 개인 자산에 액세스할 수 있는지 확인해야 할 때 인증 토큰이 있는지 확인할 수 없습니다.
이러한 변경을 수행하려면 프런트 엔드용
App.js
파일을 엽니다.- cd ..
- nano App.js
react
패키지에서useState
후크를 가져오고 새authenticated
상태 변수와 해당 setter를 초기화하여 사용자의 인증 상태를 반영합니다.import logo from './logo.svg'; import './App.css'; import { useState } from 'react' ... function App() { let [authenticated, setAuthenticated] = useState(false); if (!token) { return <Login setToken={setToken} /> } return( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
useState
후크는 유효한 인증 토큰이 프런트엔드 클라이언트용 쿠키로 활성 상태인지 알 수 있는 백엔드 API에 대한 요청을 만들어 사용자의 인증 상태를 확인합니다.다음으로
setToken
및getToken
함수, 토큰 변수 및login
구성 요소의 조건부 렌더링을 제거합니다. 그런 다음 강조 표시된 줄을 사용하여getAuthStatus
및isAuthenticated
라는 두 개의 새 함수를 만듭니다.import logo from './logo.svg'; import './App.css'; import { useState } from 'react' import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; import Login from './components/Login'; function App() { let [authenticated, setAuthenticated] = useState(false); async function getAuthStatus() { return fetch('http://localhost:8080/auth-status', { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' }, }).then(data => data.json()) } async function isAuthenticated() { const authStatus = await getAuthStatus(); setAuthenticated(authStatus.isAuthenticated); } return( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> </Switch> </BrowserRouter> </div> ); } export default App;
getAuthStatus
함수는 백엔드 앱의auth-status
경로에GET
요청을 만들어 사용자의 인증 상태를 검색합니다. 또는 사용자가 유효한 인증 토큰 쿠키와 함께 요청을 보내지 않았습니다.credentials
옵션의 값을include
로 설정하면fetch
는 브라우저가 사용자 클라이언트에 대해 쿠키로 저장할 수 있는 모든 자격 증명을 보냅니다.isAuthenticated
함수는getAuthStatus
함수를 호출하고 앱의인증됨
상태를 사용자의 인증 상태를 반영하는 부울 값으로 설정합니다.다음으로 강조 표시된 줄과 함께
useEffect
후크를 가져옵니다.import logo from './logo.svg'; import './App.css'; import { useState, useEffect } from 'react' import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; import Login from './components/Login'; function App() { let [authenticated, setAuthenticated] = useState(false); async function getAuthStatus() { return fetch('http://localhost:8080/auth-status', { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' }, }).then(data => data.json()) } async function isAuthenticated() { const authStatus = await getAuthStatus(); setAuthenticated(authStatus.isAuthenticated) } useEffect(() => { isAuthenticated(); }, []) ...
이 수정은
login
경로를 호출하여useEffect
후크에서 인증 상태를 확인합니다.useEffect
후크에 대한 빈 종속성 배열을 포함하면 애플리케이션에서 메모리 누수를 방지하는 데 도움이 될 수 있습니다.애플리케이션 홈페이지에서
login
구성 요소를 조건부로 렌더링하려면 강조 표시된 줄을 추가합니다.... function App() { let [authenticated, setAuthenticated] = useState(false); let [loading, setLoading] = useState(true) async function getAuthStatus() { await setLoading(true); return fetch('http://localhost:8080/auth-status', { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' }, }).then(data => data.json()) } async function isAuthenticated() { const authStatus = await getAuthStatus(); await setAuthenticated(authStatus.isAuthenticated); await setLoading(false) } useEffect(() => { isAuthenticated(); }, []) return ( <> {!loading && ( <> {!authenticated && <Login />} {authenticated && ( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> <Route path="/xss-helper"> <XSSHelper /> </Route> </Switch> </BrowserRouter> </div> )} </> )} </> ); } export default App;
authenticated
변수가false
로 설정된 경우 애플리케이션은login
구성 요소를 렌더링합니다. 그렇지 않으면 응용 프로그램 홈페이지와 비공개 페이지를 포함한 모든 경로가 대신 렌더링됩니다.백엔드 애플리케이션의
auth-status
경로에 대한 호출이 완료될 때까지 아무것도 렌더링하지 않도록 새loading
상태 변수를 추가합니다.인증
상태 변수는 처음에false
로 설정되기 때문에 클라이언트는authentication-status
code에 대한 API 호출이 있을 때까지 사용자가 로그인하지 않은 것으로 가정합니다. 경로가 완료되고인증된
상태 변수가 업데이트됩니다.다음으로 백엔드 API에서
logout
경로를 호출하는logoutUser
함수를 생성합니다. 강조 표시된 줄을 파일에 추가합니다.import logo from './logo.svg'; import './App.css'; import { useState, useEffect } from 'react'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import SubscriberFeed from "./components/SubscriberFeed"; import Login from './components/Login'; import XSSHelper from './components/XSSHelper' function App() { let [authenticated, setAuthenticated] = useState(false); let [loading, setLoading] = useState(true) async function getAuthStatus() { await setLoading(true); return fetch('http://localhost:8080/auth-status', { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' }, }).then(data => data.json()) } async function isAuthenticated() { const authStatus = await getAuthStatus(); await setAuthenticated(authStatus.isAuthenticated); await setLoading(false); } async function logoutUser() { await fetch('http://localhost:8080/logout', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, }) isAuthenticated(); } useEffect(() => { isAuthenticated(); }, []) return ( <> {!loading && ( <> {!authenticated && <Login />} {authenticated && ( <div className="App wrapper"> <h1 className="App-header"> JWT-Storage-Tutorial Application </h1> <button onClick={logoutUser}>Logout</button> <BrowserRouter> <Switch> <Route path="/subscriber-feed"> <SubscriberFeed /> </Route> <Route path="/xss-helper"> <XSSHelper /> </Route> </Switch> </BrowserRouter> </div> )} </> )} </> ); } export default App;
백엔드 API에서
로그아웃
경로를 호출하는 콜백 함수에onClick
속성을 설정하여 사용자를 로그아웃하는 로그아웃 버튼을 생성합니다. 경로는 클라이언트의token
쿠키를null
로 설정하는set-cookie
헤더로 응답하여 전면의 인증 상태를 효과적으로 렌더링합니다. -falsy
값으로 애플리케이션을 종료합니다.또한
logout
콜백 함수의 끝에서isAuthenticated
함수를 호출하여authenticated
상태 변수를false
로 변경합니다.완료되면 파일을 저장하고 닫습니다.
이제 HTTP 전용 쿠키 기반 토큰 저장 시스템을 테스트할 수 있습니다. 방금 수정한 내용을 구현하려면 웹 응용 프로그램을 새로 고칩니다.
그런 다음 4단계에서 공격자가 주입된 JavaScript를 통해 여전히 토큰을 훔칠 수 있는지 확인합니다.
localhost:3000/xss-helper?code=<a href="javascript:alert(`Your token object is ${localStorage.getItem('token')}. It has been sent to a malicious server >:)`);">Click Me!</a>
XSS Helper Active
줄을 보려면 사이트에 다시 로그인해야 할 수도 있습니다.Click Me!
링크를 클릭하면Your token object is null
이라는 다음 팝업이 표시됩니다.삽입된 JavaScript는
token
개체를 찾을 수 없으므로 팝업에null
값이 표시됩니다. 팝업 메시지를 닫습니다.이제 로그아웃 버튼을 눌러 앱에서 로그아웃할 수 있습니다.
이 단계에서는 인증 토큰 지속성을 위해 브라우저 저장소 사용에서 HTTP 전용 쿠키 사용으로 전환하여 애플리케이션의 보안을 개선했습니다.
결론
이 자습서에서는 Docker 컨테이너에 사용자 로그인 기능이 있는 React 및 Node 웹 애플리케이션을 만들었습니다. 사이트의 보안을 테스트하기 위해 취약한 토큰 저장 방법으로 인증 시스템을 구현했습니다. 그런 다음 반영된 XSS 공격 페이로드로 이 방법을 악용하여 브라우저 스토리지를 사용하여 인증 쿠키를 저장할 때 취약성을 평가할 수 있습니다. 마지막으로 인증 토큰을 저장하기 위해 브라우저 스토리지가 아닌 HTTP 전용 쿠키를 사용하는 인증 시스템을 설정하여 초기 구현에서 XSS 취약점을 완화했습니다. 이제 HTTP 전용 쿠키 기반 인증 토큰 시스템을 사용하는 프런트 엔드 및 백 엔드 애플리케이션이 있습니다.
애플리케이션 인증 프로세스의 보안 및 유용성을 개선하기 위해 An Introduction to OAuth 2와 같은 타사 인증 도구를 통합할 수 있습니다.
- 이 튜토리얼의 애플리케이션은