[Web] 토큰 기반 인증 로그인

웹 개발을 하면서 로그인을 구현하는데 사용되는 인증 방식 중 토큰에 대해서 이야기하고자 한다.

예시

사용자가 게시글을 남기려고 한다고 해보자. 게시글을 쓰기 위해 웹 페이지에 접속하고, 글을 쓰고, “등록” 버튼을 누른다. 하지만 그 전에 로그인이 되어있는 상태여야 한다! 그래야 누가 이 글을 작성했는지 서버에서 알 수 있고, 아무나 글을 남기지 못하게 해야 하기 때문이다. 따라서, 아래와 같은 흐름을 가진다.

user_create_post

이 과정은 내부적으로 아래와 같이 동작한다.

create_post_sequence

구현에 따라 차이가 있으나, 클라이언트에서 토큰은 쿠키에 저장하였다. 또한 클라이언트가 서버로 인증이 필요한 API를 호출할 때, HTTP 요청 헤더의 Authorization 속성에 토큰 정보를 전달한다.

쿠키가 아니라 로컬 스토리지에 저장해도 되지 않나
내가 쿠키에 저장한 것은 하나의 구현 예시일 뿐이다. 토큰을 쿠키에 저장해도 되고, 로컬 스토리지에 저장해도 상관없다고 생각한다. 관련해서 조사하다 잘 정리된 글이 있어 궁금하면 찾아가보길 바란다.
토큰을 HTTP 요청 헤더의 Authorization에만 담아야만 하나
토큰을 HTTP 헤더의 Authorization에 저장한 것은 하나의 구현 예시일 뿐이다. URL의 query string으로 전달할 수도 있고, 아니면 X-Auth-Token이란 속성도 존재하고 비슷한 역할을 한다. 자신만의 특별한 이름으로 만든 속성에 토큰을 저장하더라도 상관없지만, Authorization이 사회적으로 만연하게 퍼진 약속(?)이므로 다른 개발자와 협업을 한다면 약속을 지키는게 좋지 않을까 생각한다. 이와 관련된 StackOverflow 글이 있어 궁금하면 찾아가보길 바란다.

토큰의 장단점

장점

  1. stateless 서버를 구축할 때 사용하기 쉽다.
  2. 인증 서버/DB가 필요없다. -> 서버 자원을 절약한다.
  3. 서버 분산, 확장에 용이하다.

단점

  1. 토큰에 데이터를 담을 수 있지만, 당연히 많이 담을수록 토큰 길이는 길어진다.
  2. 토큰을 매번 HTTP 요청 헤더에 붙혀야하는데 이는 트래픽의 크기에 영향을 미친다.
  3. 서버 측에서 데이터가 변경되어도 클라이언트에 저장된 토큰은 이미 발급된 토큰이므로, 클라이언트와 서버 간 괴리가 생기는 순간이 생긴다.
  4. 보안 관련된 이슈가 있다.

1, 2번 단점은 토큰의 태생적 한계이다. 3번 단점은 토큰의 만료기한을 짧게 설정하고 토큰을 자주 발급하면 어느정도 해결될 듯 했다.
4번 단점은 해당 링크로 들어가서 보면 잘 설명되어 있으며, 요약하면 보안 관련한 쿠키 설정(HttpOnly, Secure, SameSite, cookie prefixes)을 추가하여 보안을 강화할 수 있다고 한다.

JWT (JSON Web Token)

토큰으로 흔히 사용되는 것이 바로 JWT이다.(RFC 7519) 기존의 시스템이 세션을 이용하여 회원 인증을 했었다면, 토큰을 이용한 인증은 서버는 세션을 관리하지 않으므로 서버 자원이 절약된다.

JWT의 payload에 데이터를 담을 수 있고, signature로 토큰이 조작되지 않았는지 검증할 수 있다. JWT에 대한 자세한 설명은 여기에서 참조하길 바란다.

예시 소스 코드

아래부터는 Vue.js와 Node.js(Express.js)에 대해서 어느정도 알지 못하면 이해하기 힘들 수 있다. 실제로 적용한 코드 일부분을 가져온 것이며, 토큰 기반 인증과 관련된 부분만 설명한다.

시나리오 1: 로그인

JWT 예시 소스 코드 시나리오 1

프론트는 Vue, 백엔드는 Express를 사용하여 게시판 홈페이지를 만들었고, 로그인하면 백엔드에서 토큰을 발급하는 과정이다.

BE - POST /login

먼저 벡엔드POST /login를 보자. 사용자 아이디가 존재하는지 확인한 후에(3~7번 라인), 비밀번호를 확인해서 일치하면(8~22번 라인) 토큰을 발급하는(23~32번 라인) 코드이다. DB에 쿼리를 던지고, 단방향 암호화된 비밀번호를 확인하는 과정 등이 포함되어 있다.

집중할 만한 부분은 23~32번 라인이다. 로그인 성공한 사용자 정보와, 토큰(accessToken)을 생성해서 응답을 보내는 것을 볼 수 있다. 토큰은 사용자의 아이디(id)와 권한 레벨(role)에 대한 정보를 포함한다. 27~30번 라인을 보면 getAccessToken() 함수를 통해서 해당 사용자의 idrole을 토큰에 담았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
router.post("/login", async (req, res, next) => {
  try {
    const q_VerifyUserId = "SELECT id FROM users WHERE user_identification = ?";
    const params = [req.body.userId];
    const result_VerifyUserId = await querySync(q_VerifyUserId, params);

    if (result_VerifyUserId.length === 1) {
      const q_GetUserInfo = "SELECT * FROM users where id = ?";
      const params = [result_VerifyUserId[0].id];
      const results_GetUserInfo = await querySync(q_GetUserInfo, params);

      const encryptedPassword = crypto
        .pbkdf2Sync(
          req.body.userPw,
          results_GetUserInfo[0].salt,
          cryptoConfig.interation,
          cryptoConfig.length,
          cryptoConfig.hash
        )
        .toString("base64");
      if (encryptedPassword === results_GetUserInfo[0].user_password) {
        // Authorized.
        const user = {
          nickname: results_GetUserInfo[0].nickname,
          role: results_GetUserInfo[0].role,
        };
        const accessToken = await getAccessToken(
          results_GetUserInfo[0].id,
          results_GetUserInfo[0].role
        );

        res.status(200).send({ user, accessToken });
      } else {
        res.status(200).send({ msg: "INVALID_PASSWORD" });
      }
    } else {
      if (result_VerifyUserId.length == 0)
        res.status(200).send({ msg: "INVALID_USERID" });
      else throw Error("[POST /login]: Too many rows");
    }
  } catch (err) {
    next(err);
  }
});

BE - getAccessToken()

토큰을 생성하는데는 jsonwebtoken 라이브러리를 사용한다. getAccessToken() 함수는 아래와 같다. 입력된 파라미터를 이용하여 accessToken을 생성하는 기능을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import jwt from "jsonwebtoken";
import tokenConfig from "../config/token.config";

const getAccessToken = (id, role) => {
  return new Promise((resolve, reject) => {
    jwt.sign(
      { type: "access", id, role },
      tokenConfig.access.secret,
      {
        expiresIn: tokenConfig.access.expired,
        issuer: tokenConfig.issuer,
        subject: "access",
      },
      (err, token) => {
        if (err) reject(err);
        resolve(token);
      }
    );
  });
};

위 코드에서 사용한 jwt.sign()의 매개변수 4가지는 순서대로 다음과 같다.

  • payload: 토큰에 담을 데이터 (7번 라인)
  • secretOrPrivateKey: 토큰을 암호화하는데 사용하는 비밀키 (8번 라인)
  • options: 토큰에 적용할 옵션 (기한, 발행자 등) (9 ~ 13번 라인)
  • callback : 토큰 발급 후 실행되는 콜백함수 (14 ~ 17번 라인)

벡엔드에서 로그인을 확인하고, 토큰을 발급한다면, 프론트엔드는 어떨까?

FE - Signin 컴포넌트

아래는 Signin 컴포넌트의 html/css를 제외한 javascript 코드이다. 로그인 버튼을 누르면, 입력한 아이디와 비밀번호를 전송한다. 이 때, Vuex의 Action SIGN_IN을 호출한다. 호출 결과에 따라 로그인 여부와 에러를 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { mapGetters, mapActions } from "vuex";
export default {
  name: "Signin",
  data() {
    return {
      userId: "",
      userPw: "",
    };
  },
  methods: {
    ...mapActions(["SIGN_IN"]),
    submitForm() {
      this.SIGN_IN({ userId: this.userId, userPw: this.userPw })
        .then((data) => {
          if (data.isOK) this.$router.push("/");
          else alert("로그인 정보가 올바르지 않습니다.");
        })
        .catch((err) => {
          alert("알 수 없는 에러가 발생했습니다. 다시 시도해주세요.");
          this.$router.go("/");
        });
    },
  },
};

Vue 코드가 익숙하지 않으면 무슨 의미인가 싶겠다. 결국 중요한건 로그인 버튼을 클릭하면 12번 라인 submitForm()이 실행될 것이고, SIGN_IN을 호출한다는 것이다.

그렇다면 Vuex의 SIGN_IN을 확인해보자.

FE - Vuex SIGN_IN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SIGN_IN(context, payload) {
  return new Promise((resolve, reject) => {
    singin(payload)
      .then(response => {
        if (response.data.msg) {
          resolve({ isOK: false, msg: response.data.msg });

        } else {
          Vue.$cookies.set('accessToken', response.data.accessToken, ACCESS_EXPIRED_IN);
          context.commit('signin', response.data.user);
          resolve({ isOK: true });
        }
      })
      .catch(err => reject(err));
  });
}

가장 먼저 호출되는 signin(payload)은 백엔드의 POST /login을 호출한다.

그 다음 response.data.accessToken을 쿠키에 저장하고 있다.(9번 라인) 쿠키에 저장한 토큰은 나중에 인증이 필요한 API(ex. 게시글 쓰기)를 호출할 때 사용하게 될 것이다.

시나리오 1에 따라 사용자가 로그인했다! 다음으로 게시글을 써보자.

시나리오 2: 게시글 쓰기

프론트엔드는 게시글을 등록하는 API POST /post를 호출하기 위해, HTTP 요청 헤더의 Authorization 속성에 토큰을 담고(axios의 interceptor를 이용), 사용자 입력 데이터와 함께 요청을 전송한다.

BE - POST /post

게시글을 등록하기 위해 벡엔드의 POST /post를 호출해야한다. DB에 등록된 사용자라면, 해당 사용자 아이디로 게시글을 저장한다.

정상적인 사용자라면 토큰을 확인하고, 토큰이 유효하면 게시글 저장 로직을 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
router.post("/post", async (req, res, next) => {
  try {
    if (req.headers.authorization == "null") {
      res.status(401).send();
    } else {
      const decoded = await verifyAccessToken(req.headers.authorization);
      const q_InsertPost =
        "INSERT INTO posts VALUES (null, ?, ?, ?, DEFAULT, DEFAULT, ?, null)";
      // 게시글 내용 삽입 전 처리
      let content = replaceAll(req.body.content, " ", " ");
      content = replaceAll(content, "<p></p>", "<br>");
      const params = [req.body.lectureId, decoded.id, req.body.title, content];

      const results_InsertPost = await querySync(q_InsertPost, params);
      res.status(200).send({
        isOK: true,
        insertId: results_InsertPost.insertId,
      });
    }
  } catch (err) {
    next(err);
  }
});

토큰을 확인하는 코드(3~6번 라인)는 여기서만 필요한 것이 아니기 때문에, 위 처럼 각 API마다 헤더를 확인하는 것은 좋지 않다.

아래 예시 코드처럼 미들웨어를 작성하여 적용하면 편하게 관리할 수 있다. 정규식을 통해서 URL을 필터링하고, 매칭되는 URL에 따라 토큰 유효성를 판단한다. 토큰이 없거나 유효하지 않다면 여기서 바로 응답을 줄 수 있다.

1
2
3
4
app.use("/protected/*", (req, res, next) => {
  if (authorized) next();
  else res.status(401).send();
});

BE - verifyAccessToken()

1
2
3
4
5
6
7
8
const verifyAccessToken = (token) => {
  return new Promise((resolve, reject) => {
    jwt.verify(token, tokenConfig.access.secret, (err, decoded) => {
      if (err) reject(err);
      resolve(decoded);
    });
  });
};

토큰을 해석하는 것 역시 jsonwebtoken의 문서를 참고하면 된다. 위 코드에서 사용한 jwt.verity()에서 사용한 3가지 매개변수는 다음과 같다.

  • token : 토큰 문자열
  • secretOrPublicKey : 토큰을 암호화하는데 사용했던 비밀키
  • callback : 토큰 확인 후 실행되는 콜백함수

OFF THE RECORD

프로젝트를 진행하면서 사용한 구현방식을 정리 및 공유하려 이 포스팅을 작성하는데, 공부하면서 부족했던 게 많다는 걸 느꼈다.

최근에 책 “Clean Code”를 읽고 있어서 느끼는건데, 예전에 작성한 코드들이 조금 부끄럽다. 로직을 함수로 분리해야 하는게 한 둘이 아니고, 함수 또는 변수의 이름 또한 엉망이다. 포스팅하면서 살짝살짝 수정했지만 그래도 찝찝하다. 해당 소스는 이미 내 손을 떠난 옛날 코드라 리팩토링은 하지 않지만, 앞으로 만들 코드들은 신중하게 작성하리라.

댓글 남기기