카테고리 없음

React+mysql(docker)+JWT Login 구현

Wood Pecker 2022. 7. 23. 11:53

1. 개요

     React로 작성한 클라이언트 프런트 웹페이지와 express로 작성한 서버 프로그램을 연동 시키고 docker를 이용하여 mysql을 컨테이로 실행하고  DB테이블을 만들어 로그인 프로그램을 작성하여 보았다. 로그인 이후에는  JWT를 이용하여 로그인 여부를 체크해 본다(이부분은 일부만 구현) 

 

2. PC에 프로젝트 생성(Typescript) (Front프로그램)  윈도우즈 powershell에서 다음과 같이 입력한다.

     PS> npx create-react-app test --template typescript

     PS> cd test

     PS> npm start

3.  DB연동 코드는 아래 부분에서 작성하기로 하고 일단 username과 password 를  하드코딩으로  작성한다.

import React, { useState, useRef} from "react";
import "./App.css";

function App() {
  const [isLogin, setIsLogin] = useState(false);
  var loginUser= useRef('');

  // User Login info(하드코딩 just make test easy!!)
  const database = [ //테스트 후에 삭제 
    {
      username: "user1",
      password: "1234"
    },
    {
      username: "user2",
      password: "5678"
    }
  ];

  const handleSubmit = (event:any) => {
    //Prevent page reload
    event.preventDefault();
    console.log('handleSubmit called...');
    var { uname, pass } = document.forms[0];
    const userData = database.find((user) => user.username === uname.value);

    if (userData) {
      if (userData.password !== pass.value) {
          console.log('password is not correct(hacker frendly!!)');
      } else {
        console.log('logi success!!!');
        loginUser.current=userData.username;
        setIsLogin(true);
      }
    } else {
        console.log('unknown user name(hacker frendly!!)');
    }
  };

  function LoginDialog(){
     return(
      <div className="login-form">
        <div className="title">로그인</div>
        <div className="form">
          <form onSubmit={handleSubmit}>
            <div className="input-container">
              <label>Username </label>
              <input type="text" name="uname" required />
            </div>
            <div className="input-container">
              <label>Password </label>
              <input type="password" name="pass" required />
            </div>
            <div className="button-container">
              <input type="submit" />
            </div>
          </form>
        </div>
      </div>
     );
  }

  function NormalPage(){
    var   htmlArry:any= useRef([]);
    return(
      <div>
         <h1>  Welcome {loginUser.current}님 환영합니다.</h1>
         {htmlArry.current}
      </div>
    );
  }
  return (
    <div className="app">
      {isLogin===false && <LoginDialog/>}
      {isLogin && <NormalPage />}
    </div>
  );
}

export default App;
 
4. App.css에 다음을 추가한다.
.app {
  font-family: sans-serif;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 20px;
  height: 100vh;
  font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
  background-color: #f8f9fd;
}

input[type="text"],
input[type="password"] {
  height: 25px;
  border: 1px solid rgba(0, 0, 0, 0.2);
}

input[type="submit"] {
  margin-top: 10px;
  cursor: pointer;
  font-size: 15px;
  background: #01d28e;
  border: 1px solid #01d28e;
  color: #fff;
  padding: 10px 20px;
}

input[type="submit"]:hover {
  background: #6cf0c2;
}

.button-container {
  display: flex;
  justify-content: center;
}

.login-form {
  background-color: white;
  padding: 2rem;
  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}

.list-container {
  display: flex;
}

.error {
  color: red;
  font-size: 12px;
}

.title {
  font-size: 25px;
  margin-bottom: 20px;
}

.input-container {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 10px;
}
 
 
5. 프로그램을 실행하고 테스트한다.   http://localhost:3000

6 . 서버 프로그램과 연동하기 

      위에서 작성한 프로그램은 front(client) 프로그램으로 사용자의 웹브라우저에서만 실행된다. 

     express 를 사용하여 간단한 웹서버를 만들고 이를 통하여 위에서 만든 React Front 웹프로그램을 서비스하여보자.

     서버 프로그램과 프런트 웹페이지가 상호 연동되도록 한다. 

    프로젝트 root 폴더에 다음과 같이 server.js 파일을 만든다.  

//server.js
const
express = require('express');
const path = require('path');
const app = express();

const http = require('http').createServer(app);
//app.use(express.static( path.join(__dirname, 'public')))
//app.get('/',function(request, response){
//    response.sendFile( path.join(__dirname, 'public/main.html'))
//})

app.use(express.static( path.join(__dirname, 'build')))
app.get('/',function(request, response){
    response.sendFile( path.join(__dirname, 'build/index.html'))
})

http.listen(8080, function () {
  console.log('listening on 8080')
});
윈도우즈 powershell(PS)에서 다음과 같이 명령어를 수행한다.
PS> npm run build       <== react front 프로그램을 publish 하기 위해 빌드한다,
PS> node server.js      <== express를 이용하여 만든 서버를 실행한다. react front 프로그램과 연동된다.
웹브라우저에서 http://localhost:8080 으로 접속한다.
정식으로 서비스를 할 때는 웹서버로 NGINX 또는 Apache 서버를 사용하도록 하자.

[참고] NGINX   NGINX is a free, open-source, high-performance HTTP server

        and reverse proxy, as well as an IMAP/POP3 proxy server

 

7. 도커 컨네이너를 이용한 mysql 환경설정하기

     유저 아이디와 패스워드를 하드 코딩하였는데 이제 이를 DB에 저장하고 비교하여 본다.

    (docker 서비스 프로그램 demon이 실행되고 있어야 한다. DockerDesktop을 실행한다)

    도커 환경이 PC에 설치되어 있다고 가정하고 컨테이너를 만들어 보자.  powershell에서 다음과 같이 입력한다. 

 

    ps> docker pull mysql     <== 기본 pre made 컨테이너 이미지 다운로드 받는다.

    ps> docker images        <== 다운 받은 이미지를 확인한다.

    ps> docker run --name mysql-container -e MYSQL_ROOT_PASSWORD=mysql_root_password -d -p 3306:3306 mysql:latest  --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci    <-- 컨테이너를 시작한다.

   ps> docker log   <container_id>    <== 정상동작하지 않으면 이 로그를 참조한다.

    ps> docker ps -a    <==실행중인 컨테이너 리스트를 출력한다.

    ps> docker stop mysql-container 
    ps> docker start mysql-container
    ps> docker restart mysql-container

 

   mysql docker container에 접속하기 

   > docker exec -it mysql-container bash

   # mysql -u root -p     

   mysql>   select version();
    mysql> 
CREATE USER 'reactuser'@'localhost' IDENTIFIED BY 'reactuser1234';

    mysql>  CREATE DATABASE reactuser;     <== 데이터베이스 생성

    mysql> GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost';

    mysql> GRANT ALL PRIVILEGES ON *.* TO 'reactuser'@'localhost';

    mysql>  ALTER USER 'reactuser'@localhost IDENTIFIED WITH mysql_native_password BY 'reactuser1234';

    mysql>  FLUSH PRIVILEGES;

    mysql>   contrl-d                        <== mysql에서 빠져 나온다.

    # mysql -u react-user -p           <==reactuser 이름으로  mysql에서 접속해본다. 비번은 위에서 설정하였다.

     mysql>  show databases;

     mysql>  use reactuser;

     mysql>  show tables;      <==  empty 비어있다

 

    .............. option  이 부분 생략해도 된다....................

    mysql>   contrl-d                    

    # exit   <==  컨테이너에서 빠져 나온다.[option]

 

   docker compose를 이용하여 컨테이너를 실행하여 보자. 

   ps> docker stop mysql-container   <== 기존 컨테이너를  stop을 시키고 삭제를 하여보자.
   ps> docker rm mysql-container   <== 삭제

   ps> docker ps -a 

   

  docker-compose.yml 파일을 생성한다.

version: "3.8"
volumes:
        volume_mysql:
            external: true
            name: volume_mysql

services:
    db:
        image: mysql
        container_name: mysql-container
        ports:
            - "3306:3306"
        environment:
            - MYSQL_ROOT_PASSWORD=mysql_root_password
            - TZ=Asia/Seoul
        command:
            - --default-authentication-plugin=mysql_native_password
            - --character-set-server=utf8mb4
            - --collation-server=utf8mb4_unicode_ci
            - --skip-character-set-client-handshake
        volumes:
            - volume_mysql:/var/lib/mysql
윈도우즈 파워셀에서  docker volume을 만들고 컨테이너를 실행하여 보자.

 ps>  docker volume create --name=volume_mysql    <== docker volume을 만들었다.

 ps>   docker-compose up -d

 ps> docker exec -it mysql-container bash

# exit   

..... 여기 까지 option ....

 

//server.js 에 다음을 추가하고   ps>node server.js를 실행한다. DB에 접속을 하여보자,

연결이 되는지 여부만 테스트 한다.

app.get('/adduser',function(request, response){
  console.log('get /users');
  var mysql = require('mysql');//<== npm install mysql
  var con = mysql.createConnection({
    host: "localhost",
    user: "root",
    password: "mysql_root_password",
    database : 'reactuser'
  });
  console.log('try to connect reactuser');

  con.connect(function(err) {
    if (err) {
      console.log('connection error=',err);
    } else {  
       console.log("Connected!");
 
      /*** //요부분은 아래에서 설명
       gengerateInsertsql('myemail@gmail.com','myname','pwd1234').
       then((result)=>{
           console.log('result=',result);
           con.query(result, function (err, result) {
            if (err) throw err;
            console.log("Result: " + result);
        })
      }); ***/
 
    }
   });
   response.send('Hello');
})


ps> node server.js

브라우저에서 주소 http://localhist:8080/adduser 입력하면 데이터베이스에 접속됨을 확인할 수 있다.

 

8.  데이터베이스 테이블 만들기 

 

mysql에 접속하고 Table을 만들어 보자.

mysql> CREATE TABLE login( user_id varchar(100),name TEXT,passwd TEXT,salt TEXT,secret_code TEXT,\

refresh_code TEXT, root_folder TEXT,email TEXT,sub_dept TEXT, phone_number TEXT,constraint pk_example primary key (user_id ));

 

9.  패스워드 암호화하고 비교하기 

 

아래의 코드는 사용자가 회원가입할 때 입력한 패스워드를 암호화하고

로그인 할때 입력한 패스워드와 비교해보는 테스트 프로그램이다. 

  https://zinirun.github.io/2020/12/02/node-crypto-password/

아래 코드를 server.js에 추가하고 테스트를 하여 보자. 
 
const crypto = require('crypto');    //npm install crypto
//소금 만들기
 const createSalt = () =>
    new Promise((resolve, reject) => {
        crypto.randomBytes(64, (err, buf) => {
            if (err) reject(err);
            resolve(buf.toString('base64'));
        });
    });
 
//사용자가 입력한 패스워드를 해시값으로암호화 하기
  const createHashedPassword = (plainPassword) =>
    new Promise(async (resolve, reject) => {
        const salt = await createSalt();
        crypto.pbkdf2(plainPassword, salt, 9999, 64, 'sha512', (err, key) => {
            if (err) reject(err);
            resolve({ password: key.toString('base64'), salt });
        });
    });    
 
//회원가입과 로그인 테스트
  async function getTestPassword(mypwd){  
    //회원가입시에 적용하는코드
    const { password, salt } = await createHashedPassword(mypwd);
    console.log('save to database password=',password,'  save to database salt=',salt);

    //로그인할때 적용하는 부분
    var cmpPassword="";
    crypto.pbkdf2(mypwd, salt, 9999, 64, 'sha512', (err, key) => {
         if (err) console.log('crypto.pbkdf2 error=',err);
         else {
            cmpPassword= key.toString('base64');
            console.log('cmpPassword=',key.toString('base64'));
      }

      if(password===cmpPassword){
            console.log('password correct !!!');
      } else{
           console.log('password is NOT correct !!!');
      }
   });
  }

 getTestPassword('pwd1234567');//test call!!
 
 
다음은 gengerateInsertsql()  함수를 추가로 만들고 호출하는 코드를 만들어 보았다. 
gengerateInsertsql()  함수는 회원가입시에 패스워드를 암호화하여 DB에 저장하는  sql 명령어
스트링을 만들어 주는 함수 이다. secret_code(JWT에서 사용하기 위함) 생성은  UUID로 만들었다. 
 
const { v4 } = require('uuid'); //npm install uuid
 
async function gengerateInsertsql(userId,name, mypwd){
  const { password, salt } = await createHashedPassword(mypwd);
  var cn='","';
 
   var sql='insert into login(user_id,name,passwd,salt,secret_code)            values("'+userId+cn+name+cn+password+cn+salt+cn+v4()+'")';
  return sql;
}

//  gengerateInsertsql('myemail@gmail.com','myname','pwd1234')
//    .then((result)=>{
//         console.log('result=',result);
//  })
 
app.get('/adduser',function(request, response){
     console.log('get /users');
     var mysql = require('mysql');//<== npm install mysql
    var con = mysql.createConnection({
        host: "localhost",
       user: "root",
       password: "mysql_root_password",
       database : 'reactuser'
  });
  console.log('try to connect reactuser');

  con.connect(function(err) {
    if (err) {
         console.log('connection error=',err);
    } else {  
       console.log("Connected!");  //!!!!!!!!!! 아래 부분이 추가된 코드 
 
       //하드코딩으로 사용자를 등록(회원가입)하였다. (need more works to do!!)    
       gengerateInsertsql('myemail@gmail.com','myname','pwd1234').
       then((result)=>{
              console.log('result=',result);
              con.query(result, function (err, result) {
                   if (err) throw err;
                   console.log("Result: " + result);
               })
      });
    }
   });
   response.send('Hello');
})
 
웹브라우저에서   http://localhost:8080/adduser  호출하면  login DB Table에 회원정보가 저장된다. 
회원가입 부분이 완성(?)되었다. 
이제 로그인 할때 클라이언트쪽에서 사용자가 입력한 계정 정보를 서버에서 받고 DB내용과 비교하여 보자.
 
[참고] express.bodyParser() 는 더이상 기본 설정으로 되어있지 않으므로 클라이언트가 보내오는
 데이터를 받기위해서 서버에서 아래와 같은 설정을 하여야 한다.  설정을 안하면 request.body 객체는 undefined 이다.
 
 
아래의 코드를 서버(server.js)에  추가한다.
var bodyParser = require('body-parser'); // npm install body-parse --save
app.use(bodyParser.urlencoded({extended:true}));
app.use(bodyParser.json());

app.post('/mylogin',function(request, response) { //<=  http://localhost:8080/mylogin
 
  console.log('post request ',request.body.userid,'  ',request.body.passwd);

  var mysql = require('mysql');//<== npm install mysql
  var con = mysql.createConnection({
    host: "localhost",
    user: "root",
    password: "mysql_root_password",
    database : 'reactuser'
  });
  console.log('try to connect reactuser');

  con.connect(function(err) {
    if (err) {
      console.log('connection error=',err);
      response.send('{"username":"unknown","status":"failed"}');
      throw err;
    }
    console.log("Connected!");
 
 
    //user_id should be uniqe, DB에서 읽어온다.
    var sql='select user_id,name,salt,passwd,secret_code from login where user_id="'+request.body.userid+'"';        
    console.log('sql=',sql);
    con.query(sql, function (err, result, fields) {
         if (err){
            response.send('{"username":"unknown","status":"failed"}');
            throw err;
         }
         console.log('result.length=',result.length);
         if(result.length!==1){
          response.send('{"username":"unknown","status":"failed"}');
          throw err;
         }
 
         console.log(result[0].salt);
         //// 사용자가 입력한 패스워드를 회원등록시와 똑같은 방법으로 해시값으로 변경한다.
         var salt= result[0].salt;                      //<-- DB에서 읽은 salt 값
         var dbPassword=result[0].passwd;  //<-- DB에서 읽은 패스워드 
         var cmpPassword="";
         crypto.pbkdf2(request.body.passwd, salt, 9999, 64, 'sha512', (err, key) => {
            if (err) {
              console.log('crypto.pbkdf2 error=',err);
              response.send('{"username":"unknown","status":"failed"}');
              throw err;
            }
            cmpPassword= key.toString('base64');
            console.log('cmpPassword=',key.toString('base64'));
            if(dbPassword===cmpPassword){
                console.log('password correct !!!');
                // 해시값이 서로 같으면 패스워드가 일치한다고 판단한다. 
                var str='{"username":"'+request.body.userid+'","status":"success"}';
                console.log(str);
                response.send(str);
            } else{
              console.log('password is NOT correct !!!');
              response.send('{"username":"unknown","status":"failed"}');
            }
         });
         /////
      });
  });
});

클라이언트쪽의 App.tsx 파일을 다음과 같이 수정한다. 변경후   > npm run build 를 하여준다. 
 
//새로추가된 함수(서버에 계정정보 보내기)
  const trylogin = async (in_userid:any, in_passwd:any)=>{
    console.log('trylogin...');
   
    const data_promise=  await axios.post(
      'mylogin',  //<==  서버의 app.post('/mylogin'.. 부분을 호출한다.
      {
        userid: in_userid,
        passwd: in_passwd
      }
    );
    return data_promise;
}

//기존 handleSubmit 함수를 변경한다.
  const handleSubmit = (event:any) => {
    //Prevent page reload
    event.preventDefault();
    var { uname, pass } = document.forms[0];
    console.log('handleSubmit called...','uname=',uname.value,' pass=',pass.value);
   
    const return_promise= trylogin(uname.value, pass.value);
    return_promise.then(
      function(value) { // code if successful
        //서버에서 보내온 데이터 확인하기
        console.log('value.data.username=',value.data.username);
        console.log('value.data.status=',value.data.status);
        //console.log('value.data.access_token=',value.data.access_token);
        if(value.data.status==="success"){
               loginUser.current=value.data.username;
               setIsLogin(true);
        }
      },
      function(error) { // code if some error
            console.log(error);
        }
    );
    /**** 하드 코딩 부분을 삭제한다.... 
    var { uname, pass } = document.forms[0];
    const userData = database.find((user) => user.username === uname.value);

    if (userData) {
      if (userData.password !== pass.value) {
          console.log('password is not correct(hacker frendly!!)');
      } else {
        console.log('logi success!!!');
        loginUser.current=userData.username;
        setIsLogin(true);
      }
    } else {
        console.log('unknown user name(hacker frendly!!)');
    }
    ****/
  };

정상적으로 로그인되는지 확인하여 보자.  user_id=> myemail@gmail.com',      password=> pwd1234

다음 단계는  로그인 상태를 체크하고 서비스하기 위해  session, 쿠키, JWT (Json Web Token)등의 작업이 필요하다. 

 

10. JWT 토큰 생성 

  다음은  JWT를 이용하여  토큰기반으로 로그인한 유저를 확인할 수 있도록  한다. 

 

  ps>   npm install --save jsonwebtoken

 

 다음은JWT 인증  Simple  테스트 프로그램이다.  아래 프로그램을  server.js에 입력하고 원리를 이해하여 보자.

// test code
const  jwt = require('jsonwebtoken');//  npm install --save jsonwebtoken
//토큰 생성하기 
jwt.sign({
    user_id :'myemail@gmail.com',
    name : 'myname'
  },
  'f8775e3c-e0b0-42bc-a193-270627fb02d6', //암호화 키(secret code)값으로 변경해서 사용한다.
  {
    expiresIn : '1d'     //하루동안 유효
  },
  (err, token) => {
    if (err) {
        console.log('jwt.sign error=',err);
    } else {
        console.log('jwt.sign token=',token);//<== 토큰이 생성되었다. 
        checkAutority(token);  <== 토큰이 유효한지 채크해 본다. 
    }
});
//토큰 유효한지 체크하기 
function checkAutority(accessToken) {
    jwt.verify(accessToken, 'f8775e3c-e0b0-42bc-a193-270627fb02d6',//같은 secret code를 사용
          (err, decoded) => {
            if (err) {
              console.log('jwt.verify error=',err);
            } else {
              console.log('jwt.verify success decode=',decoded);
            }
        });
 }

실행하면 다음과 같이 출력된다.
jwt.verify success decode= {
    user_id: 'myemail@gmail.com',
    name: 'myname',
    iat: 1658733144,
    exp: 1658819544
}
 
위 코드를  현재 작업하던 프로그램에 적용할 수 있다.  즉 로그인에 성공하면 서버에서 access_token을 생성하여 함께 보내준다.  그 이후로는 민감 정보를 얻고자 하는 경우에는  토큰을 서버에 보내고 서버는 이를 검증하여 처리할 수 있다.
아래의 코드는 서버에서 DB 검색을 통하여  패스워드가 확인 되면 access_token을 생성해서 client(react)에 보내주는  부분을 추가한 것이다. 
 
... server.js  기존코드 생략....
if(dbPassword===cmpPassword){
      console.log('password correct !!!');

      jwt.sign({
                  user_id :result[0].user_id,
                  name : result[0].name
                },
                result[0].secret_code,
                {
                  expiresIn : '1d'
                },
         (err, token) => {
                  if (err) {
                         console.log('jwt.sign error=',err);
                  } else {
                        console.log('jwt.sign token=',token);
                        var str='{"username":"'+request.body.userid+'","status":"success","access_token":"'+ token+'"}';
                       console.log(str);
                       response.send(str);
                      //////
                  }
              });
            }
... 기존코드 생략....
 
 
서버에서 보내준 access_code는 웹 어플리케이션 내 로컬 변수에 저장하거나(예, state 변수)  브라우저의 localStorage, 또는  cookie로 저장할 수 있다.  localStorage, 또는  cookie는 보안상 다소 취약하다고  한다(탈취 당할 수 있다).  localStorage, 또는  cookie에 저장하되  짧은 시간 동안만 유효하도록 사용하고  자주 바꾸어 주는 방법도 있울 것이다.
axio를 사용한다면  API 요청하는 콜마다 헤더에 access_token 담아 보내도록 다음과 같이 설정할 수 있다. 
 
   axios.defaults.withCredentials = true;
   axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
 
서버에서는 다음과 같이 받을 수 있다.
 
app.get('/',function(request, response){
    //...
    var header_part= request.header('authorization');
    var base64Url = Buffer.from(header_part.split('.')[1], 'base64');
    var jsonPayload= JSON.parse(base64Url);
    console.log('jsonPayload=', jsonPayload);
    //  ...
 });

https://jwt.io   에서 token이 유효한지 테스트 하여 볼 수 있다.

코드가 완성되기에는 아직 추가적으로 할 일이 많지만  대략 감은 익혔으니 소기의 목적은 이루었다. 
Thanks for reading,,,,

 

반응형