웹사이트 검색

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이라는 파일을 만들고 엽니다.

    1. 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 이미지를 생성합니다.

    1. 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

    다음 명령을 사용하여 이미지를 컨테이너로 실행합니다.

    1. docker run -d -p 3000:3000 -p 8080:8080 --name jwt-tutorial-container jwt-tutorial-image

    -d 플래그는 별도의 터미널 세션으로 연결할 수 있도록 분리 모드에서 컨테이너를 실행합니다.

    참고: Docker 컨테이너를 실행하는 데 사용하는 것과 동일한 터미널을 사용하여 개발하려면 -d 플래그를 -it로 바꾸십시오. 컨테이너 내에서 실행되는 대화형 터미널.

    -p 플래그는 컨테이너의 포트 30008080을 전달합니다. 이러한 포트는 로컬 브라우저를 사용하여 애플리케이션을 테스트할 수 있도록 호스트 시스템의 localhost 네트워크에 프런트 엔드 및 백엔드 애플리케이션을 각각 제공합니다.

    참고: 호스트 시스템이 현재 30008080 포트를 사용 중인 경우 해당 포트를 사용하는 애플리케이션을 중지해야 합니다. 포트.

    -P 플래그를 사용하여 컨테이너의 포트를 컴퓨터의 localhost 네트워크에서 사용하지 않는 포트로 전달할 수도 있습니다. 특정 포트를 매핑하는 대신 -P 플래그를 사용하는 경우 docker network inspect your_container_name을 실행하여 어떤 개발 컨테이너 포트가 있는지 확인해야 합니다. 어떤 로컬 포트에 매핑됩니다.

    원격 컨테이너 플러그인을 사용하여 VSCode와 연결할 수도 있습니다.

    별도의 터미널 세션에서 다음 명령을 실행하여 컨테이너에 연결합니다.

    1. docker exec -it jwt-tutorial-container /bin/bash

    연결되었음을 나타내기 위해 컨테이너 레이블과 함께 다음과 같은 연결이 표시됩니다.

    Output
    root@d7e051c96368:/app#

    이 단계에서는 미리 빌드된 Docker 이미지를 설정하고 개발에 사용할 컨테이너에 연결합니다. 다음으로 create-react-app를 사용하여 컨테이너에서 애플리케이션의 골격을 설정합니다.

    2단계 — 프런트 엔드 애플리케이션의 기초 설정

    이 단계에서는 React 애플리케이션을 초기화하고 ecosystem.config.js 파일을 사용하여 앱 관리를 구성합니다.

    컨테이너에 연결한 후 mkdir 명령을 사용하여 애플리케이션용 디렉터리를 만든 다음 cd 명령을 사용하여 새로 만든 디렉터리로 이동합니다.

    1. mkdir /app/jwt-storage-tutorial
    2. cd /app/jwt-storage-tutorial

    그런 다음 npx 명령을 사용하여 create-react-app 바이너리를 실행하여 웹 애플리케이션의 프런트엔드 역할을 할 새 React 프로젝트를 초기화합니다.

    1. npx create-react-app front-end

    create-react-app 바이너리는 응용 프로그램 개발 및 테스트를 위한 README 파일과 <react-scripts, react-domjest.

    설치를 계속할지 묻는 메시지가 표시되면 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를 설치합니다.

    1. npm install pm2 -g

    -g 플래그는 패키지를 전체적으로 설치합니다. 로그인한 사용자의 권한에 따라 패키지를 전체적으로 설치하려면 sudo 명령을 사용해야 할 수도 있습니다.

    PM2는 응용 프로그램의 개발 및 생산 단계에서 여러 가지 이점을 제공합니다. 예를 들어 PM2는 개발 중에 애플리케이션의 다양한 구성 요소를 백그라운드에서 실행하도록 도와줍니다. 가동 중지 시간을 최소화하면서 프로덕션 응용 프로그램을 패치하기 위한 배포 모델 구현과 같은 프로덕션의 운영 요구 사항에 PM2를 사용할 수도 있습니다. 자세한 내용은 PM2: Production-Ready Nodejs Applications in Minutes를 참조하십시오.

    설치 결과는 다음과 유사합니다.

    Output
    added 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라는 파일을 만듭니다.

    1. cd front-end
    2. 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 매개변수는 실행할 프로젝트의 루트 디렉토리를 설정합니다. scriptargs 매개변수를 사용하면 프로그램 실행을 위한 명령줄 도구를 선택할 수 있습니다. 마지막으로 env 매개변수를 사용하면 JSON 객체를 전달하여 애플리케이션에 필요한 환경 변수를 설정할 수 있습니다. 프런트 엔드 애플리케이션이 실행될 포트를 설정하는 단일 환경 변수인 PORT만 정의합니다.

    파일을 저장하고 종료합니다.

    이 명령을 사용하여 PM2 관리자가 현재 실행 중인 프로세스를 확인하십시오.

    1. pm2 list

    이 경우 현재 PM2에서 어떤 프로세스도 실행하고 있지 않으므로 다음과 같은 출력이 표시됩니다.

    Output
    ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

    명령을 실행 중이고 새로운 슬레이트를 위해 프로세스 관리자를 재설정해야 하는 경우 다음 명령을 실행하십시오.

    1. pm2 delete all

    이제 ecosystem.config.js 파일에 지정된 구성으로 PM2 프로세스 관리자를 사용하여 애플리케이션을 시작합니다.

    1. pm2 start ecosystem.config.js

    터미널에 다음과 유사한 출력이 표시됩니다.

    Output
    ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤ │ 0 │ front-end │ fork │ 0 │ online │ 0% │ 33.6mb │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

    stopstart 명령과 restartstartOrRestart 명령을 사용하여 PM2 프로세스의 활동을 제어할 수 있습니다.

    원하는 브라우저에서 http://localhost:3000으로 이동하여 애플리케이션을 볼 수 있습니다. 기본 React 시작 페이지가 표시됩니다.

    마지막으로 클라이언트 측 라우팅을 위해 react-router 버전 5.2.0을 설치합니다.

    1. 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 디렉토리를 만듭니다.

    1. mkdir src/components

    그런 다음 SubscriberFeed.js라는 components 디렉토리 안에 새 파일을 만들고 엽니다.

    1. 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 파일을 엽니다.

    1. nano src/App.js

    react-router-dom에서 BrowserRouter, SwitchRoute 구성 요소를 가져오려면 다음 강조 표시된 줄을 추가하세요. 패키지:

    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> 태그를 포함하는 AppclassName 속성이 있습니다. .

    <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 파일을 엽니다.

    1. 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 파일을 엽니다.

    1. 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-wrapperclassName을 사용하여 <div> 태그로 양식을 래핑하여 App에서 스타일을 지정할 수 있습니다. css 파일입니다.

    파일을 저장하고 닫습니다.

    프로젝트의 루트 디렉터리에서 App.css 파일을 열어 Login 구성 요소의 스타일을 지정합니다.

    1. nano src/App.css

    다음 CSS 줄을 추가하여 login-wrapper 클래스의 스타일을 지정합니다.

    ...
    
    .login-wrapper {
        display: flex;
        flex-direction: column;
        align-items: center;
    }
    

    flexdisplay 속성과 centeralign-items 속성을 사용하여 구성 요소를 페이지 중앙에 배치합니다. 그런 다음 flex-directioncolumn으로 설정하면 열에서 요소가 수직으로 정렬됩니다.

    파일을 저장하고 닫습니다.

    마지막으로 useState 후크를 사용하여 App.js 내부에 Login 구성 요소를 렌더링하여 토큰을 메모리에 저장합니다. App.js 파일을 엽니다.

    1. 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라는 새 디렉터리를 만들고 이동합니다.

    1. mkdir /app/jwt-storage-tutorial/back-end
    2. cd /app/jwt-storage-tutorial/back-end

    새 디렉터리에서 노드 프로젝트를 초기화합니다.

    1. npm init -y

    init 명령은 npm 명령줄 유틸리티에 명령이 실행되는 디렉터리에 새 노드 프로젝트를 생성하도록 지시합니다. -y 플래그는 대화형 명령줄 도구가 새 프로젝트를 만들 때 묻는 모든 초기화 질문에 대해 기본값을 사용합니다. 다음은 -y 플래그와 함께 실행되는 init 명령의 출력입니다.

    Output
    Wrote 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 프로젝트 디렉터리에 expresscors 모듈을 설치합니다.

    1. npm install express cors

    다음 출력의 일부 변형이 터미널에 나타납니다.

    Output
    added 59 packages, and audited 60 packages in 3s 7 packages are looking for funding run `npm fund` for details found 0 vulnerabilities

    index.js 파일을 만듭니다.

    1. 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 파일을 만듭니다.

    1. 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로 백엔드 애플리케이션을 실행할 수 있습니다.

    1. 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 엔드포인트가 인증 토큰을 제대로 반환하는지 평가합니다.

    1. curl localhost:8080/login

    다음 출력이 표시되어야 합니다.

    Output
    {"token":"This is a secret token"}

    이제 서버 로그인 경로가 예상대로 토큰을 반환한다는 것을 알고 있습니다.

    다음으로 API를 사용하도록 프런트 엔드 Login 구성 요소를 수정합니다. 적절한 front-end 폴더로 이동합니다.

    1. cd ..
    2. cd front-end/src/components/

    프런트 엔드 Login.js 파일을 엽니다.

    1. 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 파일을 엽니다.

    1. nano /app/jwt-storage-tutorial/front-end/src/App.js

    브라우저 저장소 통합을 시작하려면 두 개의 도우미 함수(setTokengetToken)를 정의하는 강조 표시된 줄을 추가하고 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;  
    

    두 가지 도우미 함수 setTokengetToken을 만듭니다. setToken 내에서 localStoragesetItem 함수를 사용하여 도우미 함수의 userToken 입력 매개변수에서 매핑합니다. 토큰이라는 키에. 또한 window.location 속성의 reload 기능을 사용하여 페이지를 새로 고쳐 애플리케이션이 브라우저 저장소에서 새로 설정된 토큰을 찾고 애플리케이션을 다시 렌더링할 수 있도록 합니다.

    getToken 내에서 localStoragegetItem 함수를 사용하여 token 키에 대한 값이 있는지 확인합니다. , 당신이 반환합니다. App() 함수 내에서 정의된 변수를 교체하여 getToken 함수를 사용합니다.

    사용자가 웹사이트를 방문할 때마다 프런트엔드는 브라우저 저장소에 인증 토큰이 있는지 확인하고 로그인을 요청하는 대신 이미 존재하는 토큰을 사용하여 사용자를 확인하려고 시도합니다.

    파일을 저장하고 닫은 다음 응용 프로그램을 새로 고칩니다. 이제 애플리케이션에 로그인하고 웹 페이지를 새로 고칠 수 있으며 다시 로그인할 필요가 없습니다.

    이 단계에서는 브라우저 저장소를 사용하여 토큰 지속성을 구현했습니다. 다음 섹션에서 브라우저 저장소를 사용하여 토큰 기반 인증 시스템을 활용합니다.

    6단계 - XSS 공격으로 브라우저 저장소 악용

    이 단계에서는 현재 애플리케이션에서 단계별 교차 사이트 스크립팅 공격(XSS 공격이라고도 함)을 수행하여 브라우저 스토리지를 사용하여 비밀 정보를 유지할 때 존재하는 보안 취약점을 보여줍니다. 공격은 URL 링크의 형태로 이루어지며 클릭하면 피해자를 웹 애플리케이션으로 안내하고 애플리케이션에 제작된 코드를 주입합니다. 인젝션은 사용자를 속여 상호 작용하도록 하여 악의적인 에이전트가 피해자의 브라우저에 있는 로컬 저장소의 콘텐츠를 훔칠 수 있도록 합니다.

    XSS 공격은 오늘날 가장 흔한 사이버 공격 중 하나입니다. 공격자는 일반적으로 신뢰할 수 있는 환경에서 코드를 실행하기 위해 악성 스크립트를 브라우저에 삽입합니다. 공격자는 종종 피싱 기술을 사용하여 스팸 이메일을 통해 전달되는 링크와 같이 악의적으로 제작된 링크와 상호 작용하여 사용자를 속여 브라우저 저장소의 콘텐츠를 손상시키도록 합니다.

    XSS 공격은 도메인의 브라우저 스토리지가 도메인과 연결된 모든 문서에서 실행되는 JavaScript 코드에 완전히 액세스할 수 있기 때문에 순진한 피해자의 브라우저 스토리지 콘텐츠를 훔치려는 공격자에게 특히 중요합니다. 공격자가 특정 웹 문서에 대해 사용자 브라우저에서 JavaScript 코드를 실행할 수 있는 경우 해당 문서 사용자 브라우저와 연결된 웹 도메인에 대한 사용자 브라우저 저장소(로컬 및 세션 모두)의 콘텐츠를 훔칠 수 있습니다.

    설명을 위해 URL 쿼리 매개변수를 통해 코드를 삽입할 수 있는 XSSHelper라는 구성 요소를 만들어 응용 프로그램을 의도적으로 XSS 공격에 취약한 상태로 둡니다. 그런 다음 악성 URL을 만들어 이 취약점을 악용합니다. 악성 URL은 사용자가 브라우저에서 URL로 이동하여 웹 페이지에 삽입된 의심스러운 링크를 클릭할 때 로그인한 사용자의 로컬 저장소에 액세스하여 콘텐츠를 노출합니다.

    프런트 엔드 애플리케이션의 components 디렉터리에서 XSSHelper.js라는 새 구성 요소를 엽니다.

    1. 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 후크를 가져오고 useLocationsearch 속성을 통해 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 파일을 엽니다.

    1. 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 파일을 다시 엽니다.

    1. 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.jsApp.js 파일이 수정됩니다.

    이러한 수정은 클라이언트 및 서버 코드에 대한 로그인, 로그아웃 및 인증 상태 기능을 구현합니다.

    백엔드 디렉토리로 이동하고 cookie-parser 패키지를 설치하면 Express 앱에서 쿠키를 설정하고 읽을 수 있습니다.

    1. cd /app/jwt-storage-tutorial/back-end
    2. 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를 엽니다.

    1. 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일이 지나면 쿠키가 만료되고 브라우저에 새 인증 쿠키가 필요합니다. 따라서 사용자는 사용자 이름과 비밀번호를 사용하여 다시 로그인해야 합니다.

    sameSitedomain 속성은 클라이언트 브라우저가 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 파일을 엽니다.

    1. cd ..
    2. cd front-end/src/components/
    3. 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 파일을 엽니다.

    1. cd ..
    2. 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에 대한 요청을 만들어 사용자의 인증 상태를 확인합니다.

    다음으로 setTokengetToken 함수, 토큰 변수 및 login 구성 요소의 조건부 렌더링을 제거합니다. 그런 다음 강조 표시된 줄을 사용하여 getAuthStatusisAuthenticated라는 두 개의 새 함수를 만듭니다.

    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와 같은 타사 인증 도구를 통합할 수 있습니다.