61일차 [인증/보안] Token

2021. 10. 25. 10:58
반응형

2021. 10. 21 목요일

1. Today's Key Points!🔑

  • Token
  • JWT

2. 정리해보자!🧹

Token

토큰기반 인증은 왜, 언제쓸까? 

세션 기반 인증은 서버(혹은 DB)에 유저 정보를 담는 인증 방식이다. 그래서 서버에 데이터가 계속해서 쌓이게 되고 유저가 요청을 할 때마다 서버는 서버 저장소에있는 세션 값과 일치하는지 확인하기 때문에 서버에 부담이가게 된다. 즉, 하나의 서버가 모든 요청에 대한 처리를 해야하기 때문에 서버에 부담이 온다.

이러한 부담을 덜어내기 위해 토큰기반 인증을 사용한다. 그 중 대표적인 JWT(JSON Web Token)를 알아보자.

JWT

 종류

  • Access Token : 보호된 정보들에 접근할 수 있는 권한부여에 사용한다. 권한을 부여 받는데엔 access token만 있으면 되지만, 악의적인 유저가 이 토큰을 얻어냈다면 마치 본인인것 마냥 서버에 여러가지 요청을 보낼 수 있을 것이다. 그래서 access token은 비교적 짧은 유효기간을 줘서 탈취 되더라도 오랫동안 사용할 수 없도록 한다.
  • Refresh Token : access token을 발급받기 위한 token. access token 유효기간이 만료되면 refresh token을 사용해서 새로운 access token을 발급받아서 다시 로그인할 필요가 없게된다. 

토큰기반 인증 절차

토큰 인증 절차

토큰기반 인증의 장점

  1. Statelessness & Scalability (무상태성 & 확장성)
    • 서버는 클라이언트에 대한 정보를 저장할 필요가 없다. (토큰 해독이 되는지만 판단)
    • 클라이언트는 새로운 요청을 보낼 때마다 토큰을 헤더에 포함시키면 된다. 서버를 여러개 가지고 있는 서비스라면 더더욱 빛을 발휘한다. (같은 토큰으로 여러 서버에서 인증이 가능하기 때문)
  2. 안전하다.
    • 암호화한 토큰을 사용하고, 암호화 키를 노출 할 필요가 없기 때문에 안전하다.
  3. 어디서나 생성 가능하다.
    • 토큰을 확인하는 서버가 토큰을 만들어야 하는 법이 없다.
    • 토큰 생성용 서버를 만들거나, 다른 회사에서 토큰관련 작업을 맡기는 것 등 다양한 활용이 가능하다.
  4. 권한 부여에 용이하다.
    • 토큰의 payload(내용물) 안에 어떤 정보에 접근 가능한지 정할 수 있다.

 

  설명 접속 상태 저장 위치 단점
쿠키 쿠키는 그저 http의 무상태성을 보완해주는 도구이다. 클라이언트 쿠키 그 자체는 인증이 아니다.
세션 접속 상태를 서버가 갖고 있다.
접속 상태와 권한 부여를 위해 세션 아이디를 쿠키로 전송한다.
서버 하나의 서버에서만 접속 상태를 가지므로 분산에 불리하다.
토큰(JWT) 토큰 자체가 무결성(integrity)을 증명할 수 있다. 서버가 접속 상태를 갖고 있지 않아도 된다. (stateless) 클라이언트 (쿠키, localStorage, in-memory) 모든 요청에 토큰을 실어 보내야 한다.
OAuth 2.0 제3자로부터 인증을 대행하고, access token을 받는다. 인증을 대신할 뿐, 권한 관리는 토큰 방식이랑 유사하다. 클라이언트 (쿠키, localStorage, in-memory) 위와 같음

3. Sprint 과제 복기!🧐

Server 구현

이제 마이그레이션, 데이터베이스 연결하는 것은 많이 해봤으니 넘어가겠다.

우선 토큰 생성, 해독, 응답을 보내주는 함수를 한곳에 만들어 놓고 시작을 해보자.

const dotenv = require("dotenv");
dotenv.config();
const { sign, verify } = require("jsonwebtoken");

module.exports = {
  //access token 생성
  getAccessToken: (payload) => {
    return sign(payload, process.env.ACCESS_SECRET, { expiresIn: "30s" });
  },
  //refresh token 생성
  getRefreshToken: (payload) => {
    return sign(payload, process.env.REFRESH_SECRET, { expiresIn: "1h" });
  },
  //access token 응답객체에 실어서 보내기
  sendAccessToken: (accessToken, res) => {
    res.status(200).json({ data: { accessToken }, message: "ok" });
  },
  //refresh token 쿠키에 담기
  sendRefreshToken: (refreshToken, res) => {
    res.cookie("refreshToken", refreshToken, {
      domain: "localhost",
      path: "/",
      secure: true,
      httpOnly: true,
      sameSite: "none",
    });
  },
  //refresh token으로 요청이 왔을 때, access token, userInfo 다시 보내기
  resendAccessToken: (userInfo, accessToken, res) => {
    res.status(200).json({ data: { accessToken, userInfo }, message: "ok" });
  },
  //headers.authorization 있는지 확인하고, 있다면 token을 해독한 결과를 리턴
  isAutherization: (req) => {
    const authorization = req.headers.authorization;
    if (!authorization) {
      return null;
    }
    const token = authorization.split(" ")[1];
    try {
      return verify(token, process.env.ACCESS_SECRET);
    } catch (err) {
      return null;
    }
  },
  //유효한 refresh token 인지 해독
  checkRefreshToken: (refreshToken) => {
    try {
      return verify(refreshToken, process.env.REFRESH_SECRET);
    } catch (err) {
      return null;
    }
  },
};

jwt.sign()은 토큰을 생성하는 메소드, jwt.verify()은 토큰을 해독하는 메소드이다. 공식 문서를 참고해서 다양한 메소드를 사용할 수 있다. 이러한 메소드를 사용하려면 npm install jsonwebtoken 명령어를 입력해서 모듈을 설치하면 된다.

login

const { Users } = require("../../models");
const {
  getAccessToken,
  getRefreshToken,
  sendAccessToken,
  sendRefreshToken,
} = require("../tokenFunction");

module.exports = async (req, res) => {
  const { userId, password } = req.body;
  const user = await Users.findOne({ where: { userId, password } });
  // userId, password와 일치하는 유저가 DB에 존재하지 않는 경우 로그인 요청 거절
  if (!user) {
    return res.status(401).send({ message: "not authorized" });
  }
  // 일치하는 경우 refresh token, access token을 보내준다.
  try {
    delete user.dataValues.password;
    const accessToken = getAccessToken(user.dataValues);
    const refreshToken = getRefreshToken(user.dataValues);
    sendRefreshToken(refreshToken, res);
    sendAccessToken(accessToken, res);
  } catch (err) {
    console.log(err);
  }
};

accessTokenRequest

const { Users } = require("../../models");
const { isAutherization } = require("../tokenFunction");
module.exports = async (req, res) => {
  const data = isAutherization(req);
  //headers.authorization이 없으면
  if (!data) {
    return res
      .status(401)
      .json({ data: null, message: "invalid access token" });
  }
  try {
    const { userId } = data;
    const userInfo = await Users.findOne({
      where: { userId: userId },
    });
    //유효하지 않은 access token일 경우
    if (!userInfo) {
      return res.status(401).json({ message: "invalid access token" });
    }
    //authorization도 있고, 유효한 access token일 경우 userInfo를 보내준다.
    delete userInfo.dataValues.password;
    return res.status(200).json({ data: { userInfo }, message: "ok" });
  } catch (err) {
    console.log(err);
  }
};

refreshTokenRequest

const { Users } = require("../../models");
const {
  resendAccessToken,
  checkRefreshToken,
  getAccessToken,
} = require("../tokenFunction");

module.exports = async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  //refresh token이 없을 경우
  if (!refreshToken) {
    return res
      .status(400)
      .json({ data: null, message: "refresh token not provided" });
  }
  const data = checkRefreshToken(refreshToken);
  //refresh token이 유효하지 않을 경우
  if (!data) {
    return res.status(400).json({
      data: null,
      message: "invalid refresh token, please log in again",
    });
  }
  //refresh token이 있고, 유효할 경우
  try {
    const { userId } = data;
    const userInfo = await Users.findOne({ where: { userId } });
    delete userInfo.dataValues.password;
    const accessToken = getAccessToken(userInfo.dataValues);
    resendAccessToken(userInfo.dataValues, accessToken, res);
  } catch (err) {
    console.log(err);
  }
};

Client 구현

App.js

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: "",
    };

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

  loginHandler() {
    this.setState({ isLogin: true });
  }

  issueAccessToken(accessToken) {
    this.setState({ accessToken });
  }

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

export default App;

로그인 상태, accessToken 상태를 바꿔주는 함수는 Login 컴포넌트로 넘겨줘서 Login에 성공했을 때 상태를 바꿀수 있도록 해주어야 한다.

Login Component

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

class Login extends Component {
  constructor(props) {
    super(props);
    this.state = {
      userId: "",
      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();
        this.props.issueAccessToken(res.data.data.accessToken);
      });
  }

  //생략

axios.post 에서 3번째 인자로 withCredentials:true로 넣어주는 이유는 앞 차시에서 설명을 했다. 인증정보와 함께 요청을 할 경우에는 withCredentials:true 옵션을 넣어주고 요청을 해야 응답객체를 받을 수 있다. 그래서 진짜 그런지 확인을 해보기 위해서 저 옵션을 빼고 요청을 시도해보았다. 시도해본 결과 응답이 오지 않았다. 쿠키나 headers authorization을 담아서 요청을 보낸 것이 아닌데 왜 응답을 안주는지 생각을 해보았는데, https프로토콜을 사용하기 위해 받은 인증서때문이 아닐까 라고 추측해본다.

Mypage.js

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() {
    /* 
    access token을 처리할 수 있는 api endpoint에 요청을 보내고, 받은 데이터로 Mypage 컴포넌트의 state (userId, email, createdAt)를 변경
    ** 주의사항 **
    App 컴포넌트에서 내려받은 accessToken props를 authorization header에 담아 요청을 보내야 한다. 
    */
    axios
      .get("https://localhost:4000/accesstokenrequest", {
        headers: { authorization: `Bearer ${this.props.accessToken}` },
        withCredentials: true,
      })
      .then((res) => {
        const { userId, email, createdAt } = res.data.data.userInfo;
        this.setState({ userId, email, createdAt });
        console.log("clicked accesstokenrequest ", this.state);
      });
  }

  refreshTokenRequest() {
    /*
    1. Mypage 컴포넌트의 state(userId, email, createdAt)를 변경
    2. 상위 컴포넌트 App의 state에 accessToken을 받은 새 토큰으로 교환
    */
    axios
      .get("https://localhost:4000/refreshtokenrequest", {
        withCredentials: true,
      })
      .then((res) => {
        const { userId, email, createdAt } = res.data.data.userInfo;
        this.setState({ userId, email, createdAt });
        this.props.issueAccessToken(res.data.data.accessToken);
        console.log("clicked refreshtokenrequest ", this.state);
      });
  }
// 생략

accessTokenRequest함수로 axios.get요청을 보낼때 2번째 인자로 headers의 authorization에 `Bearer ${this.props.accessToken}`에서 Bearer를 붙이는 이유는 무엇인가?

Bearer는 OAuth 2.0으로 보호되는 리소스에 액세스하기 위한 전달자 토큰이다. 헤더에 Authorization : <type> <credentials> 이런 형식에 맞춰서 보내줘야한다. 즉, Bearer는 type중 하나이다.   

<참고> https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization

반응형
LIST

BELATED ARTICLES

more