본문 바로가기
프로그래밍/웹 개발

[node.js + React] 쿠키와 토큰을 활용해 로그인 기능 구현

by 제이콥J 2021. 9. 11.

코드스테이츠에서 토큰 인증을 통해 로그인 기능을 구현하는 과제를 마쳤다.

진행 과정 및 코드는 다음과 같다.

 

<HTTP 통신 순서>

1. 클라이언트 Login 페이지 : userId, password 입력 후 로그인 시도 (post 요청)

 

2. 서버의 login 컨트롤러 : request body로 받은 userId, password 와 일치하는 유저 조회

  - 해당 유저가 없으면 실패 응답 보내기

  - 해당 유저가 있으면 refreshToken을 생성하여 cookie로 전달하고, accessToken을 생성하여 body data로 전달

 

3. 클라이언트의 Login 페이지 : 서버의 성공 응답 받기

  - 로그인 상태를 true로 바꾸기

  - 서버에서 받은 AccessToken을 상태로 저장하기

 

4. 클라이언트의 app.js : login 상태가 true로 바뀌었으므로 Login 페이지 대신 Mypage 랜더링하기

 

5. 클라이언트의 Mypage : accessToken을 바탕으로 서버에 유저 정보 요청 (get 요청)

 

6. 서버의 accessTokenRequest 컨트롤러 : accessToken을 해독하여 유저 데이터 전달

  - accessToken이 없거나 해독할 수 없는 경우 실패 메세지 전달

  - accessToken이 만료된 경우 실패 메세지 전달

  - 문제가 없는 경우 accessToken 과 일치하는 담긴 유저 데이터를 전달

 

7. 클라이언트의 Mypage : 쿠키의 refreshToken을 바탕으로 유저 정보와 새 accessToken 요청 (get)

 

8. 서버의 refreshTokenRequest 컨트롤러 : 쿠키에 refreshToken 이 담겨있는지 확인

- refreshToken 없는 경우, 실패 메세지 전달

- refreshToken 이 만료된 경우, 재로그인 필요하므로 실패 메세지 전달

- 유효한 경우, refreshToken을 해독하여 유저 정보를 얻고, accessToken을 새로 생성하여 전달

 

<코드 구현 순서>

1. 서버 : user 모델 생성 후 login, accessTokenRequest, refreshTokenRequest 컨트롤러 제작하기

2. 클라이언트 : app.js, Login & Mypage 컴포넌트 제작하기

 

*공식문서 링크 : https://github.com/auth0/node-jsonwebtoken

 


서버의 기본 설정

 

1. mkcert로 사설 인증서를 발급하여 package.json 이 있는 폴더에 옮기기

 

2. Sequelize로 User 모델 생성하기

 

3. .env 파일 : MySQL 접속 정보, accessToken/refreshToken의 salt 입력하기

 

4. express를 통한 미들웨어 설정하기

 

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 }));

// CORS 설정
app.use(
  cors({
    origin: ["https://localhost:3000"],
    credentials: true,
    methods: ["GET", "POST", "OPTIONS"],
  })
);

// 요청된 쿠키를 쉽게 추출할 수 있도록 도와주는 미들웨어 
// request 객체에 cookies 속성 부여
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 프로토콜을 사용하는 서버를 실행
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;

 


서버의 login 컨트롤러 제작

1. 클라이언트의 요청 body에 담긴 userId, password와 일치하는 유저를 DB에서 찾기

 

2. 해당 유저 정보가 존재하지 않으면, 실패 응답 전달하기

 

3. 해당 유저 정보가 존재한다면, 두 종류의 jwt 토큰을 생성하며 응답으로 보내기

  - jwt 토큰 생성 시 유저 정보 중에서 password는 제외하기 (보안 이슈로 굳이 토큰에 password를 담을 필요는 없음)

  - password를 제외한 유저 데이터를 담아 accessToken을 생성하고, 응답 body로 보내기

  - password를 제외한 유저 데이터를 담아 refreshToken을 생성하고, cookie에 담아서 보내기

 

// 조회한 객체에서 유저 정보를 꺼내기 : userInfo.dataValues

console.log(userInfo)

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
  },
  _changed: Set(0) {},
  _options: {
    isNewRecord: false,
    _schema: null,
    _schemaDelimiter: '',
    raw: true,
    attributes: [ 'id', 'userId', 'password', 'email', 'createdAt', 'updatedAt' ]
  },
  isNewRecord: false
}

 

코드 작성

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

module.exports = async (req, res) => {
  const { userId, password } = req.body;
  const userInfo = await Users.findOne({where: {userId:userId, password:password}})

  if(!userInfo) { // 실패 메세지
    res.status(400).json({ "data": null, "message": "not authorized" })
  } else {
    const userTokenInfo = userInfo.dataValues  // 유저 정보 담기
    delete userTokenInfo.password
    
    // 토큰 생성하기
    const accessToken = jwt.sign(
      userTokenInfo,              // 유저 정보
      process.env.ACCESS_SECRET,  // 일종의 salt
      { expiresIn: 60 * 60 }      // 옵션 중에서 만료기간
    )
    const refreshToken = jwt.sign(
      userTokenInfo,
      process.env.REFRESH_SECRET, 
      { expiresIn: '1d' }
    )
    
    // 토큰 전달하기
    res.cookie('refreshToken', refreshToken, {
      sameSite: 'none',
      httpOnly: true,
      secure: true,
    }).status(200).json({ "data": { accessToken }, "message": "ok" })
  }
};

서버의 accessToken 컨트롤러 제작

1. 클라이언트 요청 헤더 객체의 authorization 속성에 accessToken이 담겨있는지 확인하기

  - console.log (req.headers)

// authorization 속성이 없는 경우
{
  host: '127.0.0.1:4000',
  'accept-encoding': 'gzip, deflate',
  connection: 'close'
}

// authorization 속성이 있는 경우
{
  host: '127.0.0.1:4000',
  'accept-encoding': 'gzip, deflate',
  authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcklkIjoia2ltY29kaW5nIiwiZW1haWwiOiJraW1jb2RpbmdAY29kZXN0YXRlcy5jb20iLCJjcmVhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJ1cGRhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJpYXQiOjE2MzE0MzY1MTd9.2RsiZ1Cq59kCIwJvCUZQ7cKAPTb8DNBJxvKPx5NKTeQ',
  connection: 'close'
}

2. req.headers에 authorization 속성이 없거나 그 안의 accessToken을 해독할 수 없으면, 실패 메세지 전달하기

 

3. req.headers의 accessToken을 해독하고, 그 유저 데이터와 일치하는 유저가 있는지 조회

  - jwt를 해독하여 얻은 payload 안의 유저데이터 값으로 DB의 유저를 조회하기

  - 일치하는 유저가 없으면 실패 메세지 전달하기

  - 일치하는 유저가 있으면 그 유저의 데이터를 전달하기

 

코드 작성

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

module.exports = async (req, res) => {
  
  const authorization = req.headers['authorization'];
  
  if(!authorization) {
    res.json({ "data": null, "message": "invalid access token" })
  }

  const token = authorization.split(' ')[1];  // accessToken만 추출하기 위한 문법
  const tokenData = jwt.verify(token, process.env.ACCESS_SECRET);
  
  /* 
  console.log(tokenData)
  {
    id: 1,
    userId: 'kimcoding',
    email: 'kimcoding@codestates.com',
    createdAt: '2020-11-18T10:00:00.000Z',
    updatedAt: '2020-11-18T10:00:00.000Z',
    iat: 1631436750
  }
  */

  // 토큰 정보에 해당하는 유저 정보 조회
  const { userId } = tokenData
  const userInfo = await Users.findOne({where : {userId}})
  
  // 유저 정보가 없으면 실패 메세지를 전달하고, 있으면 성공 메세지와 유저 정보 전달
  if(!userInfo) {
    res.json({ "data": null, "message": "access token has been tempered" })
  } else {
    delete userInfo.dataValues.password;
    res.json({"data": {userInfo : userInfo.dataValues}, "message": "ok"})
  }
};

 

* 레퍼런스 코드 중

- 나는 'authorization 객체와 accessToken이 없으면 유효한 토큰이 아니다'라는 로직으로 코드 작성

- 레퍼런스 코드에서는 accessToken의 해독이 불가능할 경우 null을 리턴하여 유효성을 검증함

// isAuthorized 라는 내장함수 따로 만듬

isAuthorized: (req) => {

    const authorization = req.headers["authorization"];
    
    if (!authorization) {   // authorization 속성이 없는 경우
      return null;
    }
    const token = authorization.split(" ")[1];
    try {
      return verify(token, process.env.ACCESS_SECRET);
    } catch (err) {    // accessToken 해독이 불가능한 경우
      return null;     // return null if invalid token
    }
  },
  
  // 내장함수에 요청(req)를 넣어 실행하고 변수에 따로 담아줌
  const accessTokenData = isAuthorized(req);
  if (!accessTokenData) {
    return res.json({ data: null, message: 'invalid access token' });
  }

 


서버의 refreshToken 컨트롤러 제작

1. 클라이언트 요청의 쿠키에 담긴 refreshToken 의 존재여부와 유효성 확인

- refreshToken은 req.headers에 담겨 있음

- express의 cookie-parser 모듈 덕분에 req.cookies 에서도 확인 가능

 

// refreshToken이 없는 경우

console.log(req.headers)
{
  host: '127.0.0.1:4000',
  'accept-encoding': 'gzip, deflate',
  connection: 'close'
}

console.log(req.cookies)
[Object: null prototype] {}


// refreshToken이 유효하지 않은 경우

console.log(req.headers)
{
  host: '127.0.0.1:4000',
  'accept-encoding': 'gzip, deflate',
  cookie: 'refreshToken=invalidtoken',
  connection: 'close'
}

console.log(req.cookies)
{ refreshToken: 'invalidtoken' }


// refreshToken이 유효할 때

console.log(req.headers)
{
  host: '127.0.0.1:4000',
  'accept-encoding': 'gzip, deflate',
  cookie: 'refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcklkIjoia2ltY29kaW5nIiwiZW1haWwiOiJraW1jb2RpbmdAY29kZXN0YXRlcy5jb20iLCJjcmVhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJ1cGRhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJpYXQiOjE2MzE0NTY1MTh9.zQDNB9QZB08Z-7U6vZOu5F0hd5ukNK4BLODBgiLdIa8',
  connection: 'close'
}

console.log(req.cookies)
{
  refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcklkIjoia2ltY29kaW5nIiwiZW1haWwiOiJraW1jb2RpbmdAY29kZXN0YXRlcy5jb20iLCJjcmVhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJ1cGRhdGVkQXQiOiIyMDIwLTExLTE4VDEwOjAwOjAwLjAwMFoiLCJpYXQiOjE2MzE0NTY1MTh9.zQDNB9QZB08Z-7U6vZOu5F0hd5ukNK4BLODBgiLdIa8'
}

 

2. refreshToken이 없다면 실패 메세지 전달

3. refreshToken이 유효하지 않다면 실패 메세지 전달

4. refreshToken을 해독하여 토큰의 유저 데이터와 일치하는 유저 검색하기

  - 일치하는 유저가 없으면 실패 메세지 전달

  - 일치하는 유저가 있으면 해당 유저 데이터를 전달하고, 새로운 accessToken을 생성하고 전달하기

 

코드 작성

const { Users } = require('../../models');
let jwt = require('jsonwebtoken')
const dotenv = require("dotenv");
dotenv.config();

module.exports = async (req, res) => {
  
  let {refreshToken}= req.cookies

  if(!refreshToken){    // refreshToken이 없는 경우
    res.send({ data: null, message: "refresh token not provided" })
  }
  
  if(refreshToken == "invalidtoken"){    // refreshToken이 유효하지 않은 경우
   res.send({ "data": null, "message": "invalid refresh token, please log in again" })
  }
  
  // refreshToken 해독
  const data = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
 
  let {userId} = data
  const userInfo = await Users.findOne({
    where: {userId: userId}
  })
   
  // 새로운 accessToken 생성
  const newAccessToken = jwt.sign(userInfo.dataValues, process.env.ACCESS_SECRET, { expiresIn: 60 * 60 })
  
  // 해당 유저가 있으면 새로운 accessToken과 유저 데이터 전송
  if(!userInfo) {
    res.send({ data: null, message: "refresh token has been tempered" })
  } else {
    let {id, userId, email, createdAt, updatedAt} = userInfo
    res.send({ data: {accessToken: newAccessToken, userInfo: {id, userId, email, createdAt, updatedAt} }, message: "ok" })
  }
};

 

* 레퍼런스 코드 중

- 나는 refreshToken 의 유효성을 refreshToken 값에 따라서 검증함 (refreshToken=invalidtoken)

- 레퍼런스 코드에서는 refreshToken의 해독이 불가능할 경우 null을 리턴하여 유효성을 검증함

 

 // refreshToken의 유효성 검사를 위한 내장함수
 checkRefeshToken: (refreshToken) => {
    try {
      return verify(refreshToken, process.env.REFRESH_SECRET);
    } catch (err) {
      // return null if refresh token is not valid
      return null;
    }
  }
 
 // 내장함수를 실행하여 변수에 담기
 const refreshTokenData = checkRefeshToken(refreshToken);
 if (!refreshTokenData) {
  return res.json({
    data: null,
    message: 'invalid refresh token, please log in again',
  });
}

 


클라이언트의 app.js 파일 제작

1. 상태(state)로 관리할 값 : 로그인 여부(Boolean), accessToken

2. 메소드(setState) : 로그인 메소드, accessToken 업데이트 메소드

3. 로그인이 되어 있을 경우, Mypage 컴포넌트를 브라우저에 렌더링하기

4. 로그인이 안 되어 있을 경우, Login 컴포넌트를 브라우저에 렌더링하기

 

import React, { Component } from "react";

import Login from "./components/Login";
import Mypage from "./components/Mypage";

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      isLogin: false,    // 로그인 여부
      accessToken: "",   // accessToken
    };

    this.loginHandler = this.loginHandler.bind(this);
    this.issueAccessToken = this.issueAccessToken.bind(this);
  }

  
  loginHandler(data) {  // 로그인 핸들러
  this.setState({       // 로그인 여부 수정
    isLogin: true
  })
  this.issueAccessToken(data.data.accessToken)
  }

  issueAccessToken(token) {  // accessToken 상태 업데이트
  this.setState({
    accessToken: token
  })
  }

  render() {
    const { isLogin } = this.state;
    return (
      <div className='App'>
        {isLogin ? 
        (<Mypage 
          accessToken = {this.state.accessToken}
          loginHandler = {this.loginHandler}
          issueAccessToken={this.issueAccessToken}
         />) : 
         (<Login 
          loginHandler = {this.loginHandler}
          issueAccessToken={this.issueAccessToken}
         />)}
      </div>
    );
  }
}

export default App;

 

클라이언트의 Login 컴포넌트 제작

1. 유저가 입력할 userId 와 password 를 상태로 관리하기

2. 유저의 키보드 입력에 따라 userId 와 password 를 변경할 이벤트 핸들러 제작

 

3. 서버에 로그인 요청을 위한 메소드 제작하기

  - userId과 password를 요청 body에 담고, 서버에 로그인을 요청하기 (post 요청)

  - 로그인에 성공하면 로그인 상태를 true로 변경하고, 응답 body로 받은 accessToken 로 상태 변경

 

import axios from "axios";
import React, { Component } from "react";

class Login extends Component {
  constructor(props) {
    super(props);
    this.state = {
      userId: "",     // userId 상태
      password: "",   // password 상태
    };
    this.inputHandler = this.inputHandler.bind(this);
    this.loginRequestHandler = this.loginRequestHandler.bind(this);
  }

  inputHandler(e) {
    this.setState({ [e.target.name]: e.target.value });
  }

  loginRequestHandler() {    // 로그인 요청
   axios.post("https://localhost:4000/login", 
     { userId: this.state.userId, password: this.state.password},
     { withCredentials: true })
     .then(res => {
       this.props.loginHandler(res.data)    
     })
  }

  render() {
    return (
      <div className='loginContainer'>
        <div className='inputField'>
          <div>Username</div>
          <input
            name='userId'
            onChange={(e) => this.inputHandler(e)}
            value={this.state.userId}
            type='text'
          />
        </div>
        <div className='inputField'>
          <div>Password</div>
          <input
            name='password'
            onChange={(e) => this.inputHandler(e)}
            value={this.state.password}
            type='password'
          />
        </div>
        <div className='loginBtnContainer'>
          <button onClick={this.loginRequestHandler} className='loginBtn'>
            JWT Login
          </button>
        </div>
      </div>
    );
  }
}

export default Login;

 

클라이언트의 Mypage 컴포넌트 제작

1. accessToken을 바탕으로 서버에 유저 정보를 요청하기 위한 메소드 제작하기 (get)

2. accessToken을 바탕으로 유저 정보와 새로운 accessToken을 요청하기 위한 메소드 제작하기 (get)

 

import axios from "axios";
import React, { Component } from "react";

class Mypage extends Component {
  constructor(props) {
    super(props);
    this.state = {
      userId: "",
      email: "",
      createdAt: "",
    };
    this.accessTokenRequest = this.accessTokenRequest.bind(this);
    this.refreshTokenRequest = this.refreshTokenRequest.bind(this);
  }

  accessTokenRequest() {    // accessToken을 바탕으로 유저 정보 요청
    axios.get("https://localhost:4000/accesstokenrequest", {
      headers: { Authorization: `Bearer ${this.props.accessToken}`}, withCredentials:true }
    ).then(info=>{
      this.setState(info.data.data.userInfo)
    })
  }

  refreshTokenRequest() { // refreshToken을 바탕으로 유저 정보 및 새로운 accessToken 요청
    axios.get("https://localhost:4000/refreshtokenrequest", {
      withCredentials: true,
    })
    .then(info=>{
      const userInfo = info.data.data.userInfo
      const newToken = info.data.data.accessToken
      this.setState(userInfo)
      this.props.issueAccessToken(newToken)
      }
    )
  }

  render() {
    const { userId, email, createdAt } = this.state;
    return (
      <div className='mypageContainer'>
        <div className='title'>Mypage</div>
        <hr />
        <br />
        <br />
        <div>
          안녕하세요. <span className='name'>{userId ? userId : "Guest"}</span>님! jwt 로그인이
          완료되었습니다.
        </div>
        <br />
        <br />
        <div className='item'>
          <span className='item'>나의 이메일: </span> {email}
        </div>
        <div className='item'>
          <span className='item'>나의 아이디 생성일: </span> {createdAt}
        </div>
        <br />
        <br />
        <div className='btnContainer'>
          <button className='tokenBtn red' onClick={this.accessTokenRequest}>
            access token request
          </button>
          <button className='tokenBtn navy' onClick={this.refreshTokenRequest}>
            refresh token request
          </button>
        </div>
      </div>
    );
  }
}

export default Mypage;
반응형

댓글