[인증/보안] Sprint - Token - Server(1)

윤후·2022년 3월 24일
0

Section 3

목록 보기
34/41

Sprint 풀이


먼저 npm 모듈을 설치하고 파일을 하나씩 살펴보자.

Server - index.js

require("dotenv").config();
const fs = require("fs");
const https = require("https");
const cors = require("cors");
const cookieParser = require("cookie-parser");

const express = require("express");
const app = express();

const controllers = require("./controllers");

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(
  cors({
    origin: ["https://localhost:3000"],
    credentials: true,
    methods: ["GET", "POST", "OPTIONS"],
  })
);
app.use(cookieParser());
app.post("/login", controllers.login);
app.get("/accesstokenrequest", controllers.accessTokenRequest);
app.get("/refreshtokenrequest", controllers.refreshTokenRequest);

const HTTPS_PORT = process.env.HTTPS_PORT || 4000;

// 인증서 파일들이 존재하는 경우에만 https 프로토콜을 사용하는 서버를 실행합니다. 
// 만약 인증서 파일이 존재하지 않는경우, http 프로토콜을 사용하는 서버를 실행합니다.
// 파일 존재여부를 확인하는 폴더는 서버 폴더의 package.json이 위치한 곳입니다.
let server;
if(fs.existsSync("./key.pem") && fs.existsSync("./cert.pem")){

  const privateKey = fs.readFileSync(__dirname + "/key.pem", "utf8");
  const certificate = fs.readFileSync(__dirname + "/cert.pem", "utf8");
  const credentials = { key: privateKey, cert: certificate };

  server = https.createServer(credentials, app);
  server.listen(HTTPS_PORT, () => console.log("server runnning"));

} else {
  server = app.listen(HTTPS_PORT)
}
module.exports = server;

제일 먼저들어오는 것은 역시나 index.js이다. 이전엔 express-session으로 cookie에 대한 값을 설정해주었었는데, 현재 session을 사용하지 않으므로 cookie에 대한 설정은 따로 해주고 있지 않다.

또한 이전 스프린트에서 cors를 만지지 않았던 실수가 있었는데, 이번엔 따로 cors가 미들웨어로 설정이 되어 있는걸 볼 수 있다.

Server - .env

DATABASE_PASSWORD=
DATABASE_USERNAME=root
DATABASE_NAME=authentication
ACCESS_SECRET=shit
REFRESH_SECRET=pee

그 다음 환경변수를 설정해줄 .env 파일을 살펴보았다. 현재 데이터베이스에 접속할 MySQL에 관한 비밀번호를 설정해주면 되겠다. 처음엔 ACCESS_SECRET과 REFRESH_SECRET의 값이 비어있었지만 해당 값은 내가 넣어주었다.

Server - models/index.js

'use strict';

const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.js')[env];
const db = {};

let sequelize;
// 데이터베이스와 연결을 진행합니다.
if (config.use_env_variable) {
  sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
  sequelize = new Sequelize(
    config.database,
    config.username,
    config.password,
    config
  );
}
//models 폴더 내부에 존재하는 파일들을 읽어와 findOne, findAll과 같은 함수를 실행할수 있게끔 모델 인스턴스를 생성합니다.
fs.readdirSync(__dirname)
  .filter((file) => {
    return (
      file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'
    );
  })
  .forEach((file) => {
    const model = require(path.join(__dirname, file))(
      sequelize,
      Sequelize.DataTypes
    );
    //db.users와 같이 db 객체 내부에 모델 인스턴스를 저장합니다.
    db[model.name] = model;
  });
//associate 부분에 내용이 존재한다면 자동으로 관계를 형성합니다.
Object.keys(db).forEach((modelName) => {
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

db.sequelize = sequelize;
db.Sequelize = Sequelize;
//여러 모델 인스턴스가 담긴 객체를 내보냅니다.
module.exports = db;

현재 sequelize를 사용하고 있어서 위와 같은 코드가 만들어져 있는것을 볼 수 있겠다. 이번에도 models폴더 안에 있는 파일 이름을 따가지고와서 db라는 객체에 넣고, 이 데이터를 contoller에서 찾아 사용할 수 있도록 만들어 두는것 같다.

Server - models/user.js

'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class Users extends Model {
    /**
   * 해당 파일은 시퀄라이즈 ORM이 데이터베이스 쿼리를 진행하기 위해
   * 해당되는 테이블의 이름 및 어트리뷰트의 특성을 지정하는 파일입니다.
   * 또한 아래 associate 부분을 통해서 다른 테이블과의 관계를 작성할수 있습니다.
   * 
   * 이 파일을 바탕으로 시퀄라이즈는 서버가 실행되면  findOne, findAll과 같은 함수를
   * 사용할수 있게 준비를 하게 합니다.
   */
    static associate(models) {

    }
  }
  Users.init(
    {
      id: {
        type: DataTypes.NUMBER,
        primaryKey: true,
      },
      userId: DataTypes.STRING,
      password: DataTypes.STRING,
      email: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'Users',
    }
  );
  return Users;
};

Server - controller/users/login.js

const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

module.exports = async (req, res) => {
  // TODO: urclass의 가이드를 참고하여 POST /login 구현에 필요한 로직을 작성하세요.
  
  const userInfo = await Users.findOne({
    where : { userId : req.body.userId, password : req.body.password},
  })

  // console.log(userInfo.id, userInfo.userId, userInfo.email, userInfo.createdAt, userInfo.updatedAt)
  
  if(!userInfo){
    res.status(400).json({data: null, message: 'not authorized' })
  }else{
    const payload = {id : userInfo.id, userId : userInfo.userId, email : userInfo.email, createdAt :  userInfo.createdAt, updatedAt : userInfo.updatedAt}
  
    const AccessToken = jwt.sign(payload, process.env.ACCESS_SECRET, {expiresIn:"30s"})
    // ! 환경변수에 접근하기 위해서 process.env 를 사용해야 한다.
    const RefreshToken = jwt.sign(payload, process.env.REFRESH_SECRET, {expiresIn:"5m"})

    res.cookie("refreshToken",RefreshToken,{ sameSite:"None", secure:true, httpOnly:true})

    res.status(200).json({data:{"accessToken" : AccessToken}, message: 'ok'})
    // ! status가 cookie보다 먼저 나와버리면, 응답이 종료되어 버려서 cookie가 나가고 난 뒤에 맨 마지막에 응답으로 마무리가 되어야함.
    
  }
};

로그인을 했을 경우에 라우터가 들어오는 부분이다. 원래는 아무 코드도 들어있지 않았지만 밑에서 설명하기로 한다.

Server - controller/users/accessTokenRequest.js

const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

module.exports = async (req, res) => {
  // TODO: urclass의 가이드를 참고하여 GET /accesstokenrequest 구현에 필요한 로직을 작성하세요.
  // console.log(req.headers)
  
  const token = req.headers.authorization
  if(!token){
    res.status(400).json({data : null, message :'invalid access token'})
  }
  const realtoken = token.split(" ")[1]
  // console.log(realtoken)

  const vat = jwt.verify(realtoken, process.env.ACCESS_SECRET)
    // console.log(vat)

    if(!vat) {
      res.status(400).json({data : null, message :'invalid access token'})
    }
    else{

      const userInfo = await Users.findOne({
        where : {id : vat.id, userId : vat.userId, email : vat.email, createdAt : vat.createdAt, updatedAt : vat.updatedAt}
      })
      // console.log(result)
      if(userInfo){
        res.status(200).json({data : { userInfo : {id : userInfo.id, userId : userInfo.userId, email : userInfo.email, createdAt :  userInfo.createdAt, updatedAt : userInfo.updatedAt}}, message : 'ok'})
      }
      else{
        res.status(400).json({data : null, message :"access token has been tempered"})
      }
    }
};

AccessToken이 존재하는지 확인하는 곳의 라우터로 연결되어 있는 파일이다. 간단하게 말해서 AccessToken이 있고 없느냐를 따지는 곳이라고 보면 되겠다.

Server - controller/users/refreshTokenRequest.js

const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

module.exports = async (req, res) => {
  // TODO: urclass의 가이드를 참고하여 GET /refreshtokenrequest 구현에 필요한 로직을 작성하세요.
   // console.log("req",req.cookies)

  const token = req.cookies.refreshToken
  // console.log(req.cookies.refreshToken)

  if(!token){
    res.status(400).json({data:null, message:'refresh token not provided'});
  }

  if(token === 'invalidtoken'){
    res.status(400).json({data:null, message:'invalid refresh token, please log in again'})
    res.redirect('https://localhost:3000')
  }

  const vrt = jwt.verify(token, process.env.REFRESH_SECRET)
  // console.log(vrt)
  
  const userInfo = await Users.findOne({
    where : { id : vrt.id, userId : vrt.userId }
  })
  if(!userInfo){
    res.status(400).json({ "data": null, "message": "refresh token has been tempered" })
  }else{
    const payload = {id : userInfo.id, userId : userInfo.userId, email : userInfo.email, createdAt :  userInfo.createdAt, updatedAt : userInfo.updatedAt}
    const AccessToken = jwt.sign(payload, process.env.ACCESS_SECRET,{expiresIn:"30s"})
    res.status(200).json({data:{"accessToken" : AccessToken, "userInfo" : {id : userInfo.id, userId : userInfo.userId, email : userInfo.email, createdAt :  userInfo.createdAt, updatedAt : userInfo.updatedAt}}, message: 'ok'})
  }

  
};

AcessToken과 마찬가지로, 이번에 RefreshToken이 있는지 없는지를 판별하는 파일이 되겠다.

스프린트의 설명을 들어가기 전에 토큰이 어떻게 클라이언트로 들어가게 되는지를 살펴봐야 한다.

토큰이라는건 session과 같이 정보를 저장하는데, 이 정보를 클라이언트에 저장하는것이고, 그 데이터는 암호화 되어 있는 것이라고 간단하게 말할 수 있겠다. 헌데 이 토큰이라는 것을 어떻게 받아올까?

⇒ 클라이언트가 로그인을 하기 위해서 아이디와 비밀번호를 입력하고 해당 데이터를 서버에게 보낸다. 서버는 이 정보가 데이터베이스에 있는지 확인하고, 데이터가 일치하면 클라이언트에게 토큰을 보내는데 http-header에 담아 두 가지의 토큰을 보내게 된다.

하나는 AccessToken, 나머지 하나는 RefreshToken이다. (하지만 이번 스프린트에서는 RefreshToken은 쿠키에 담아서 보내야한다.) AccessToken은 서버에게 인증을 할 수 있는 토큰이라고 생각하면 되고, RefreshToken은 AccessToken을 받아올 수 있는 토큰이라고 생각하면 되겠다.

그럼 먼저, controller/users/login을 보자.

Server - controller/users/login.js

const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

module.exports = async (req, res) => {
  // TODO: urclass의 가이드를 참고하여 POST /login 구현에 필요한 로직을 작성하세요.
  
  const userInfo = await Users.findOne({
    where : { userId : req.body.userId, password : req.body.password},
  })

  // console.log(userInfo.id, userInfo.userId, userInfo.email, userInfo.createdAt, userInfo.updatedAt)
  
  if(!userInfo){
    res.status(400).json({data: null, message: 'not authorized' })
  }else{
    const payload = {id : userInfo.id, userId : userInfo.userId, email : userInfo.email, createdAt :  userInfo.createdAt, updatedAt : userInfo.updatedAt}
  
    const AccessToken = jwt.sign(payload, process.env.ACCESS_SECRET, {expiresIn:"30s"})
    // ! 환경변수에 접근하기 위해서 process.env 를 사용해야 한다.
    const RefreshToken = jwt.sign(payload, process.env.REFRESH_SECRET, {expiresIn:"5m"})

    res.cookie("refreshToken",RefreshToken,{ sameSite:"None", secure:true, httpOnly:true})

    res.status(200).json({data:{"accessToken" : AccessToken}, message: 'ok'})
    // ! status가 cookie보다 먼저 나와버리면, 응답이 종료되어 버려서 cookie가 나가고 난 뒤에 맨 마지막에 응답으로 마무리가 되어야함.
    
  }
};

제일 먼저 생각해야할 것은 로그인의 요청이 들어오면 해당 유저가 데이터베이스에 있는 유저인지 파악해야 한다는 것이다.

데이터베이스와 연결하기 위해 sequelize로 했던 models를 require로 가져온다.

const { Users } = require('../../models');

이제 데이터베이스를 Users를 통해 가져올 수 있게 되었다.

이후, 로그인에 대한 정보를 어디서 가지고 오는지를 생각해보자. 현재 요청으로 들어오는 곳은 req밖에 없다. 하여 console.log(req)를 해보았더니 엄청난 데이터가 들어있는것을 확인할 수 있었다.

답은 urclass에 있었다. 로그인에 필요한 정보가 HTTP요청의 body에 담아서 전송된다고 한다. 그렇다면 데이터가 어떻게 요청이 들어오고 있는지 알아보기 위해 console을 찍어보자.

commend

console.log(req.body)

Result

{ userId: 'kimcoding', password: 'helloWorld' }
{ userId: 'kimcoding', password: '1234' }
{ userId: 'kimcoding', password: '1234' }
{ userId: 'kimcoding', password: '1234' }

위와 같이 결과가 나오는 것을 볼 수 있다.

그렇다면, 위의 결과로 데이터베이스에서 데이터를 가지고 오려면 어떻게 가지고 올 수 있을까? 공식문서를 살펴보자.

참조

Sequelize

우리는 현재 데이터베이스에 접근하기 위해서 sequelize를 사용하고 있다. 저번에 사용했던것과 같이, findOne을 사용하여 데이터를 가지고 오면 되겠다.

const { Users } = require('../../models');

module.exports = async (req, res) => {
  // TODO: urclass의 가이드를 참고하여 POST /login 구현에 필요한 로직을 작성하세요.
  
  const userInfo = await Users.findOne({
    where : { userId : req.body.userId, password : req.body.password},
  })
}

현제 데이터를 가져와야하는건 비동기적으로 실행이 되어야 하기 때문에 async와 await을 사용하였고, 조건문을 요청으로 들어온 body에 userId와 password로 확인을 하였다.

과연 userInfo에는 데이터가 잘 들어가 있을까?

commend

console.log(userInfo)

Result

Users {
  dataValues: {
    id: 1,
    userId: 'kimcoding',
    password: '1234',
    email: 'kimcoding@codestates.com',
    createdAt: 2020-11-18T10:00:00.000Z,
    updatedAt: 2020-11-18T10:00:00.000Z
  },
  _previousDataValues: {
    id: 1,
    userId: 'kimcoding',
    password: '1234',
    email: 'kimcoding@codestates.com',
    createdAt: 2020-11-18T10:00:00.000Z,
    updatedAt: 2020-11-18T10:00:00.000Z
  },
  uniqno: 1,
  _changed: Set(0) {},
  _options: {
    isNewRecord: false,
    _schema: null,
    _schemaDelimiter: '',
    raw: true,
    attributes: [ 'id', 'userId', 'password', 'email', 'createdAt', 'updatedAt' ]
  },
  isNewRecord: false
}

위와 같은 데이터를 가지고 있는 것이 확인이 되었다. 그래서 나는 처음에 해당하는 값에 접근하기 위해서, 아래와 같은 코드로 접근해야 하는줄 알았다.

console.log(userInfo.Users.dataValues.id)

하지만 이렇게 console을 찍어보니 아무런 값은 커녕 timeout에러가 뜨면서 오류가 나는 것이었다. 하여 나는 다시 값을 가져오기 위해서 값들을 직접 찍어보기로 했다.

commend

console.log(userInfo.id, userInfo.userId, userInfo.email, userInfo.createdAt, userInfo.updatedAt)

Result

'kimcoding','kimcoding@codestates.com','1234','2020-11-18 10:00:00','2020-11-18 10:00:00'

위와 같은 데이터가 나타나면서 다시 알았다. userInfo에 접근할 때에는 위와 같이 접근을 해야겠구나 생각했다.

현재 userInfo로 데이터베이스에서 값도 잘 가져오고 있으니, 조건을 생각해야 했다.

  1. 만약 userInfo가 없을 경우, 즉 데이터베이스에 해당 유저의 아이디와 비밀번호가 없을 경우를 얘기한다.

    그렇다면 코드로 아래와 같이 구현할 수 있겠다.

if(!userInfo){
    res.status(400).json({data: null, message: 'not authorized' })
  }

여기서 data를 null로 넣어준 이유는 클라이언트에서 데이터 값을 받을 때, null 아무것도 없다는 값을 넘겨주기 위해서 이다. data라고 키의 이름을 지어준 것은 내가 원한건 아니지만 테스트 케이스를 통과하기 위해서 키를 data라고 지어주었다.

  1. 만약 데이터가 데이터베이스에 존재하여 userInfo가 있을 경우에는 어떻게 해야할것인가?

    해당 유저가 있을 경우에는 AccessToken과 RefreshToken을 같이 전해주어야한다. 여기서는 RefreshToken을 쿠키에 담아서 보내야 한다.

    하지만 현재 AccessToken과 RefreshToken이 없기 때문에 새로 만들어 주어야 한다. 우리는 jwt 토큰을 이용하여 만들고 있다. 직접 구현하기 전, 공식문서에 예제를 살펴보면서 어떻게 만들어야 할지 고민해보자.

    먼저 해당 모듈을 install하고 선언을 해주어야할 것이다.

참조

jsonwebtoken

JWT.IO

Example

**jwt.sign(payload, secretOrPrivateKey, [options, callback])**

위의 문장이 jwt토큰을 만드는 것이다. 첫 번째의 값이 payload에 들어가고, 두 번째로 salt가 들어가며 세 번째 인자로 옵션, callback함수가 들어간다고 한다.

⇒ 뭔말이여?

사실 위와 같이 떠들면 뭔말인지 모른다. 간단하게 말하면 payload에는 클라이언트에게 전달해줄 값이 객체형태로 들어가게 되는 것이고, 두 번째는 말 그대로 salt가 들어가고 세 번째에는 토큰의 유효기간이나, 어떤 해싱 알고리즘을 사용할 것인지에 대한 값을 넣으면 된다.

말로만해서는 모른다 직접 보자.

const jwt = require('jsonwebtoken');

// 예제1
jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256' }, function(err, token) {
  console.log(token);
});

// 예제2
jwt.sign({
  data: 'foobar'
}, 'secret', { expiresIn: '1h' });

위와 같은 코드를 보면 우리가 이제 넣어주어야할(payload) 데이터가 어떤 것인지를 먼저 파악해야겠다.

urclass에 보면 어떤 데이터를 넣어야할지 알려주고 있다.

  • 필요한 데이터(id, userId, email, createdAt, updatedAt)를 payload에 담아 JWT token을 생성합니다.
  • access token, refresh token 두 가지를 생성합니다.
  • access/refresh token은 각각 다른 비밀 키를 이용하여 생성합니다(환경 변수에 저장된 ACCESS_SECRET, REFRESH_SECRET 값을 사용하세요).
  • 일반적으로 access token의 유효기간은 refresh token의 유효기간보다 짧습니다.

자 이제 우리가 jwt를 만드는데 필요한 조건은 모두 모인것 같다. 위의 정보로 코드를 만들어보자.

const jwt = require('jsonwebtoken')

const payload = {id : userInfo.id, userId : userInfo.userId, email : userInfo.email, createdAt :  userInfo.createdAt, updatedAt : userInfo.updatedAt}
  
const AccessToken = jwt.sign(payload, process.env.ACCESS_SECRET, {expiresIn:"30s"})
// ! 환경변수에 접근하기 위해서 process.env 를 사용해야 한다.

const RefreshToken = jwt.sign(payload, process.env.REFRESH_SECRET, {expiresIn:"5m"})

위와 같이 코드를 짤 수 있겠다. 여기서 salt로 들어가는 부분은 우리가 만들어주고 있는 환경변수를 이용하여 들어가야하는 값인것이다. 사실 이 부분은 ACCESS_SECRET만 사용하였는데, 환경변수에 접근하기 위해서는 process.env을 이용해야 접근할 수 있었다.

그렇다면 우리가 salt로 들어갈 값을 정해주어야겠다. 다시 .env파일로 들어가보자.

.env

DATABASE_PASSWORD=
DATABASE_USERNAME=root
DATABASE_NAME=authentication
ACCESS_SECRET=shit
REFRESH_SECRET=pee

환경변수를 설정하고 각각의 token이 잘 생성되었다 확인하기 위해 console을 찍어보자.

commend

console.log(AccessToken)
console.log(RefreshToken)

Result

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcklkIjoia2ltY29kaW5nIiwiZW1haWwiOiJraW1jb2RpbmdAY29kZXN0YXRlcy5jb20iLCJjcmVhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJ1cGRhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJpYXQiOjE2NDc1MjAwNDQsImV4cCI6MTY0NzUyMDA3NH0.tg-OQVeeYqPn8u_4j7cVLPlMUSllnLiBWqGn-egMO0s

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcklkIjoia2ltY29kaW5nIiwiZW1haWwiOiJraW1jb2RpbmdAY29kZXN0YXRlcy5jb20iLCJjcmVhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJ1cGRhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJpYXQiOjE2NDc1MjAwNDQsImV4cCI6MTY0NzUyMDM0NH0.1RxXDxy0FE-HnlBidxgLHj2IYs7quZLHaCvkKU-RNdw

위와 같이 엄청난 값들이 해싱처리가 되어 나타나는 것을 볼 수 있다!
이제 다왔다. 이정보를 클라이언트에게 넘겨주기만 하면 된다.

RefreshToken은 쿠키에 담아주어야 하고 AccessToken은 http-header에 담아 보내주어야한다.
우리는 현재 express를 사용하고 있기 때문에 cookie에 담아주기 위해서 공식문서를 보았다.

참고

4.x API

Example

**res.cookie(name, value [, options])**

첫 번째 인자로 넣고 싶은 키의 이름이 들어간다. 두 번째 인자로 해당 키의 값이 들어가며, 세 번째에는 cookie에 관한 설정이 들어가는 걸 볼 수 있다.

res.cookie('name', 'tobi', { domain: '.example.com', path: '/admin', secure: true })

위의 예시를 이용해서 쿠키에 refreshToken을 담아보자.

res.cookie("refreshToken",RefreshToken,{ sameSite:"None", secure:true, httpOnly:true})

쿠키를 보내기 위해서는 반드시 설정해주어야 할 것이 있다. 옵션에서 위에서 설정해준 값들이다. cors 사용을 위해서 sameSite의 사용을 “None”으로 잡았고(도메인을 넣어도 된다.) https를 사용하기 위해서 secure옵션을 true,

나머지는 urclass에서 데이터를 어떻게 보내야할지를 알려주고 있다.
accessToken을 data객체에 담아 보내면 되겠다.

res.status(200).json({data:{"accessToken" : AccessToken}, message: 'ok'})

자 이제 그럼 login.js는 끝났다.

profile
궁금한걸 찾아보고 공부해 정리해두는 블로그입니다.

0개의 댓글

관련 채용 정보