2023.12.25(월)

🔐인증과 인가

  • 인증(Authentication)
    • 사용자가 로그인(사이트에 가입된 사용자라는 것을 증명)하는 것
    • 신원 확인
  • 인가(Authorization)
    • 인증 받은(로그인 상태의) 사용자가 서비스를 사용할 때 서버가 허가하는 것
    • 특정 리소스에 대한 액세스 권한 부여

🎫JWT(JSON Web Token)

  • 쿠키, 세션, 토큰, 캐시, CDN 기본 개념
    • 쿠키(cookie)
      • 사용자의 브라우저에 저장되는 정보
    • 세션(session) stateful
      • 서버가 사용자를 기억하고 있는 상태
    • 토큰(token) stateless
      • 서버가 기억해둘 필요 없이 사용자가 스스로 증명할 수 있는 수단
    • 캐시(cache)
      • 다시 가져오지 않아도 되도록 데이터를 가까이 저장해두는 기술
      • 메인 메모리 속 데이터에 보다 더 빠르게 접근할 수 있도록 CPU에 내장되는 형태
    • CDN(Content Delivery Network)
      • 각지에 캐시 서버를 두어 부하를 분산시키는 기술

🔗공식 사이트 jwt.io

🔑JWT 구조

점 (.)으로 구분된 세 부분으로 구성: xxxxx.yyyyy.zzzzz

  • Header
    • 일반적으로 다음 두 부분으로 구성
      • Signature or encryption algorithm (HMAC SHA256 or RSA) = Signature 값을 만드는데 사용되는 알고리즘/암호화 방식

      • Type of token = JWT

        {
          "alg": "HS256",
          "typ": "JWT"
        }
    • Base64Url로 인코딩되어 JSON Web Token의 첫번째 파트를 구성
  • Payload
    • 이 token을 누가 누구에게 발급했는지, 언제까지 유효한지, 서비스가 사용자에게 이 token을 통해 공개하길 원하는 내용(사용자 닉네임, 서비스상의 레벨, 관리자 여부 등)을 서비스 측에서 원하는대로 담을 수 있음
    • Token에 담긴 사용자 정보 등의 데이터를 Claim이라고 부름
      {
        "sub": "1234567890",
        "name": "John Doe",
        "admin": true
      }
    • Base64Url로 인코딩되어 JSON Web Token의 두번째 파트를 구성

⚠️ 변조로부터 보호되지만 이 정보는 Base64Url로 인코딩되어 있어 누구나 읽을 수 있기 때문에 암호화되지 않은 경우에는 JWT의 payload나 header 요소에 비밀 정보를 넣으면 안됨

  • Signature
    • header와 payload, ‘서버에 감춰놓은 비밀 값’, 이 세 가지를 암호화 알고리즘에 넣고 돌리면 나오는 signature 값
      HMACSHA256(
        base64UrlEncode(header) + "." +
        base64UrlEncode(payload),
        secret
      )

🛡️JWT 동작 과정

  • 사용자는 protected route나 resource에 access하려고 할 때마다 Bearer schema를 사용하여 Authorization header에 JWT를 보냄
    Authorization: Bearer <token>
  • 서버는 요청에 token 값이 실려 들어오면 head와 payload 값을 ‘서버의 비밀 키’와 함께 돌려서 계산된 결과값이 signature 값과 일치하는지 확인


1. The application or client requests authorization to the authorization server. This is performed through one of the different authorization flows. For example, a typical OpenID Connect compliant web application will go through the /oauth/authorize endpoint using the authorization code flow.
2. When the authorization is granted, the authorization server returns an access token to the application.
3. The application uses the access token to access a protected resource (like an API).

💡 JWT와 같은 token 방식의 경우 stateless하기 때문에 토큰이 만료될 때까지 사용자의 상태를 제어할 수 없다. 그래서 만료시간을 가깝게 잡아서 토큰의 수명을 아주 짧게 한다. 물론 사용자가 로그인을 자주해야 하므로 로그인을 하고 나면 토큰을 두 개 발급한다.
1. 수명이 몇 시간이나 몇 분 이하로 짧은 access token (매번 인가를 받을 때 사용)
2. 보통 2주 정도로 수명이 꽤 긴 refresh token (DB에도 저장)
client는 access token의 수명이 다하면 refresh token을 보내고 서버는 DB에 저장된 값과 client가 보낸 refresh token을 대조해서 맞으면 새로운 access token을 발급한다. 따라서 사용자의 상태를 제어하려면 refresh token을 조작하면 되지만 access token이 짧게나마 살아 있는 동안에는 여전히 제어가 불가능하기 때문에 한계가 있다.

👩‍💻node.js에서 JWT 사용해보기

  • jsonwebtoken 설치 : npm install jsonwebtoken

    npm: jsonwebtoken

  • Usage

    • Basic

      const jwt = require('jsonwebtoken');  // jwt 모듈 import
      const privateKey = 'secret-key';
      
      /**
       * jwt.sign(payload, secretOrPrivateKey, [options, callback])
       */
      let token = jwt.sign({ user: 'do0ori', mail: 'do0ori@mail.com' }, privateKey);    // token 발행 (default: HS256)
      console.log(token); // noTimestamp라는 option이 지정되지 않은 경우 생성된 jwt는 iat (issued at) claim을 포함
      
      /**
       * jwt.verify(token, secretOrPublicKey, [options, callback])
       */
      let decoded = jwt.verify(token, privateKey);
      console.log(decoded);   // { user: 'do0ori', mail: 'do0ori@mail.com', iat: 1703489109 }
      
      jwt.verify(token, 'wrong-key', function(err, decoded) {
          if (err) console.log(err);  // JsonWebTokenError: invalid signature
          else console.log(decoded);
      });

    ❓ token 발행 및 검증 시에 사용할 private key와 같이 외부에 유출되면 안되는 중요한 정보는 어떻게 안전하게 보관할까? ⇒ .env 사용

    • .env = Node.js 내에서 필요한 값들을 정리해놓은 환경변수 파일(프로젝트 최상위 경로, root 폴더에 위치)
      PRIVATE_KEY = "secret-key"  # JWT 암호키
      ⇒ 추후 .env 파일은 .gitignore 파일에 추가해 github에 올라가지 않도록 하면 됨!
    • dotenv 모듈로 .env 파일에 저장한 환경 변수 사용 가능
      • dotenv 설치 : npm install dotenv --save
      • Usage
        const dotenv = require('dotenv')
        dotenv.config()
        console.log(process.env.PRIVATE_KEY)    // secret-key
    • 환경 변수 사용
      const jwt = require('jsonwebtoken');  // jwt 모듈 import
      const dotenv = require('dotenv'); // dotenv 모듈 import
      dotenv.config();
      
      /**
       * jwt.sign(payload, secretOrPrivateKey, [options, callback])
       */
      let token = jwt.sign({ user: 'do0ori', mail: 'do0ori@mail.com' }, process.env.PRIVATE_KEY);    // token 발행 (default: HS256)
      console.log(token); // noTimestamp라는 option이 지정되지 않은 경우 생성된 jwt는 iat (issued at) claim을 포함
      
      /**
       * jwt.verify(token, secretOrPublicKey, [options, callback])
       */
      let decoded = jwt.verify(token, process.env.PRIVATE_KEY);
      console.log(decoded);   // { user: 'do0ori', mail: 'do0ori@mail.com', iat: 1703489109 }
      
      jwt.verify(token, 'wrong-key', function(err, decoded) {
          if (err) console.log(err);  // JsonWebTokenError: invalid signature
          else console.log(decoded);
      });

🏗️미니 프로젝트 (유튜브)에 JWT 적용해보기

📦Dependencies

  • server에서 발행한 JWT는 cookie에 담아 client에 보내줘야 하고 🔗
    client는 발급받은 JWT를 cookie에 저장해뒀다가 이후에 request를 보낼 때 함께 보내야함 🔗
  • 추후 req에 있는 cookie를 꺼내 쓰기 위해 cookie-parser 설치 : npm install cookie-parser

💻CODE Update

  • 폴더 구조

    .
    │  .env
    │  app.js
    │  mariadb.js
    │  package-lock.json
    │  package.json
    │
    ├─node_modules
    │
    └─routes
            channels.js
            users.js
  • .env

    PORT = "8888"   # express port number
    PRIVATE_KEY = "secret-key"  # JWT secret key
  • mariadb.js (변경 없음)

    // get the client
    const mysql = require('mysql2');
    
    // create the connection to database
    const connection = mysql.createConnection({
        // host: 'localhost',
        // port: 3306,
        user: 'root',
        password: 'root',
        // timezone: 'Asia/Seoul',
        database: 'Youtube',
        dateStrings: true
    });
    
    module.exports = connection;
  • app.js

    • JWT private key뿐만 아니라 port도 .env에 저장해서 사용해보았다.
    const express = require('express');  // npm install express
    const app = express();
    const dotenv = require('dotenv');
    dotenv.config();
    
    let port = process.env.PORT || '3000';
    app.set('port', port);
    app.listen(port, () => console.log(`> Server is running on http://localhost:${port}/`));
    
    const userRouter = require('./routes/users');
    const channelRouter = require('./routes/channels');
    
    app.use(express.json());
    app.use('/', userRouter);
    app.use('/channels', channelRouter);
  • routes/users.js

    const express = require('express');
    const router = express.Router();
    const conn = require('../mariadb');
    const { body, param, validationResult } = require('express-validator');
    
    const channelNotFound = (res) => res.status(404).json({ message: "채널이 존재하지 않습니다." });
    const validate = (req, res, next) => {
        const err = validationResult(req)
        if (err.isEmpty()) {
            next();
        } else {
            return res.status(400).json(err.array());
        }
    };
    
    router.route('/')
        .get(   // 채널 전체 조회
            [
                body('userId').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                const { userId } = req.body;
    
                let sql = 'SELECT * FROM `channels` WHERE user_id = ?';
                conn.query(
                    sql, userId,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        if (results.length) {
                            res.status(200).json(results);
                        } else {
                            channelNotFound(res);
                        }
                    }
                );
            })
        .post(  // 채널 개별 생성
            [
                body('userId').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                body('name').notEmpty().isString().withMessage('문자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                const { name, userId } = req.body;
    
                let sql = 'INSERT INTO `channels` (name, user_id) VALUES (?, ?)';
                let values = [name, userId];
                conn.query(
                    sql, values,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        res.status(201).json({ message: `${name} 채널을 응원합니다.`, results });
                    }
                );
            });
    
    router.route('/:id')
        .get(   // 채널 개별 조회
            [
                param('id').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                let { id } = req.params;
                id = parseInt(id);
    
                let sql = 'SELECT * FROM `channels` WHERE id = ?';
                conn.query(
                    sql, id,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        if (results.length) {
                            res.status(200).json(results);
                        } else {
                            channelNotFound(res);
                        }
                    }
                );
            })
        .put(   // 채널 개별 수정
            [
                param('id').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                body('name').notEmpty().isString().withMessage('채널명 오류'),
                validate
            ],
            (req, res) => {
                let { id } = req.params;
                id = parseInt(id);
                let { name } = req.body;
    
                let sql = 'UPDATE `channels` SET name = ? WHERE id = ?';
                let values = [name, id];
                conn.query(
                    sql, values,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        if (results.affectedRows != 0) {
                            res.status(200).json({ message: `채널명이 ${name}(으)로 성공적으로 수정되었습니다.`, results });
                        } else {
                            channelNotFound(res);
                        }
                    }
                );
            })
        .delete(    // 채널 개별 삭제
            [
                param('id').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                let { id } = req.params;
                id = parseInt(id);
    
                let sql = 'DELETE FROM `channels` WHERE id = ?';
                conn.query(
                    sql, id,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
                        if (results.affectedRows != 0) {
                            res.status(200).json({ message: `채널이 삭제되었습니다.`, results });
                        } else {
                            channelNotFound(res);
                        }
                    }
                );
            });
    
    module.exports = router;
  • routes/channels.js (변경 없음)

    const express = require('express')
    const router = express.Router()
    const conn = require('../mariadb')
    const { body, param, validationResult } = require('express-validator')
    
    const channelNotFound = (res) => res.status(404).json({ message: "채널이 존재하지 않습니다." })
    const validate = (req, res, next) => {
        const err = validationResult(req)
        if (err.isEmpty()) {
            next()
        } else {
            return res.status(400).json(err.array())
        }
    }
    
    router.route('/')
        .get(   // 채널 전체 조회
            [
                body('userId').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                const { userId } = req.body
    
                let sql = 'SELECT * FROM `channels` WHERE user_id = ?'
                conn.query(
                    sql, userId,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        if (results.length) {
                            res.status(200).json(results)
                        } else {
                            channelNotFound(res)
                        }
                    }
                )
            })
        .post(  // 채널 개별 생성
            [
                body('userId').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                body('name').notEmpty().isString().withMessage('문자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                const { name, userId } = req.body
    
                let sql = 'INSERT INTO `channels` (name, user_id) VALUES (?, ?)'
                let values = [name, userId]
                conn.query(
                    sql, values,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        res.status(201).json({ message: `${name} 채널을 응원합니다.`, results })
                    }
                )
            })
    
    router.route('/:id')
        .get(   // 채널 개별 조회
            [
                param('id').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                let { id } = req.params
                id = parseInt(id)
    
                let sql = 'SELECT * FROM `channels` WHERE id = ?'
                conn.query(
                    sql, id,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        if (results.length) {
                            res.status(200).json(results)
                        } else {
                            channelNotFound(res)
                        }
                    }
                )
            })
        .put(   // 채널 개별 수정
            [
                param('id').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                body('name').notEmpty().isString().withMessage('채널명 오류'),
                validate
            ],
            (req, res) => {
                let { id } = req.params
                id = parseInt(id)
                let { name } = req.body
    
                let sql = 'UPDATE `channels` SET name = ? WHERE id = ?'
                let values = [name, id]
                conn.query(
                    sql, values,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
    
                        if (results.affectedRows != 0) {
                            res.status(200).json({ message: `채널명이 ${name}(으)로 성공적으로 수정되었습니다.`, results })
                        } else {
                            channelNotFound(res)
                        }
                    }
                )
            })
        .delete(    // 채널 개별 삭제
            [
                param('id').notEmpty().isInt().withMessage('숫자를 입력해주세요.'),
                validate
            ],
            (req, res) => {
                let { id } = req.params
                id = parseInt(id)
    
                let sql = 'DELETE FROM `channels` WHERE id = ?'
                conn.query(
                    sql, id,
                    function (err, results) {
                        if (err) {
                            return res.status(400).json(err);
                        }
                        if (results.affectedRows != 0) {
                            res.status(200).json({ message: `채널이 삭제되었습니다.`, results })
                        } else {
                            channelNotFound(res)
                        }
                    }
                )
            })
    
    module.exports = router

강의 마지막에 송아 강사님께서 언급하신 데이터베이스 페이징(Paging)에 대해 간단히 알아봤다. DB에서 데이터를 읽어와서 화면에 출력할 때 한꺼번에 모든 데이터를 가져오지 않고 출력될 페이지의 데이터만 나눠서 가져오는 것을 페이징이라고 한다고 한다. 일반적으로 쿼리문에서 LIMIT(가져올 데이터 양), OFFSET(시작 위치)을 이용해서 구현하는 것 같다.

profile
이것저것 관심 많은 개발자👩‍💻

0개의 댓글