JS/TIL(Today I Learned)

2024-12-06

프린스 알리 2024. 12. 6.

내일배움캠프 Node.js 트랙 29일차

API 테스트 트러블 슈팅

웹 토큰 발행을 통한 인증 기능 구현하기.
우리는 액세스 토큰과 리프레쉬 토큰을 게임 사용자 인증에 적용하고자 노력했다.

(우리 팀의 프로젝터에서 로그인과 auth 미들웨어가 작동하는 방식)

  • 만료시간이 매우 짧은 Access Token을 Authorization 헤더를 통해 발급하는 방식.
  • 사용자가 로그인하면 Refresh Token을 발급하여 세션 DB에 저장한다. 이때 'x-info'헤더에 사용자 email을 반환하고, Access Token이 만료되었을 때 이 email 데이터를 통해 Refresh Token의 발급 여부를 확인한다.
  • DB에 저장된 Refresh Token이 유효하다면 Access Token을 새로 갱신한다.
  • Refresh Token이 유효하지 않다면 다시 로그인을 요청한다.

CASE 1: INSOMNIA

Insomnia는 API를 테스트하기 위한 개발 플랫폼이다. 기본적으로 쿠키로 토큰을 발급하게 되면 요청을 할 때 쿠키가 자동으로 적용이 된다. 하지만 헤더를 통해 토큰을 발급한다면? 아무것도 전달되지 않을 것이다. 예외 처리를 했다면 401 상태 코드가 우리를 반길 터.

이 문제를 해결하려고 고민을 했던 건 사실 이 프로젝트가 아닌 이전 프로젝트에서였다.

res.setHeader('authorization', `Bearer ${accessToken}`);

위와 같이 'authorization'헤더에 발급해준 Access Token을 요청 헤더가 참조할 수 있는 방법이 필요했다. 그래서 Stack Overflow에서 나와 같은 고민을 했던 사람이 있는지 검색을 해보았다. 그리고 다음과 같은 링크를 찾았다.

 

How to chain requests using Insomnia (get token from login api to use as header for another api) - Stack Overflow

 

How to chain requests using Insomnia (get token from login api to use as header for another api)

I'm trying to update the header for my apis with a sif token that is retrieved from another login call. I know how to do this in Postman. There I go to the Tests tab and add something like this for...

stackoverflow.com

 

이곳에서 환경변수의 힌트를 얻을 수 있었다.

Response로 반환한 값을 Request의 Body에서 참조할 수 있다면, Header에서도 참조할 수 있지 않을까?
그래서 바로 시도를 해보았고, 그 결과는 성공적이었다.

이렇게 알아낸 방법을 다른 사람들에게도 공유하면 좋을 것 같아서 슬랙을 통해 옵시디언 노트를 배포하였던 게 지난 프로젝트에서 있었던 일이다.
https://share.note.sx/ek8743at#aWb1x5pN6GH4YCaBmcZB6XheXB8yuL2AtPnItzhAcLg

 

https://share.note.sx/ek8743at#aWb1x5pN6GH4YCaBmcZB6XheXB8yuL2AtPnItzhAcLg

...

share.note.sx

CASE 2: WEB FRONTEND

그런데 팀 프로젝트에 접어들고 나서 난이도가 더욱 올라갔다. 이번에는 Access Token뿐만 아니라 Refresh Token도 발급하고 있기 때문이다. 게다가 이번엔 Insomnia가 아닌 실제 웹에서 프론트엔드를 만들어보기로 결정했기 때문에 만만치 않은 문제들이 잇따랐다.

 

우리는 수많은 에러를 맞닥뜨렸고, 그 중 대부분은 아주 막연한 종류들이었다. 인증 미들웨어에 중단점을 찍어도 진입조차 못하고 401(인증 오류) 에러를 내버린다든지, Access Token이 만료되면 전혀 다른 유저의 계정으로 로그인이 되어버린다든지…보안상으로 아주 취약한 문제였다.

 

프론트엔드 작업을 돕기로 결정했던 12월 6일 아침, 나는 이 문제를 해결하기 위해서 일단은 인증 미들웨어를 다시 읽어보기로 결정했다. 아래는 팀원이 작성하였던 인증 미들웨어의 전문이다.

export default async function authM(req, res, next) {
    try {
        const { authorization } = req.headers;
        console.log(authorization); // 추출 확인용

        // Authorization 헤더가 없을 경우
        if (!authorization) throw new Error('토큰이 존재하지 않습니다.');
        const [tokenType, token] = authorization.split(' ');

        if (tokenType !== 'Bearer')
            throw new Error('토큰 타입이 일치하지 않습니다.');

        // 엑세스 토큰 검증
        const decodedToken = jwt.verify(token, process.env.SERVER_ACCESS_KEY);
        const accountsData = decodedToken.accountId;

        // 데이터베이스에서 사용자 정보 조회
        const accounts = await prisma.account.findFirst({
            where: { accountId: accountsData },
        });

        // 사용자가 존재하지 않을 경우
        if (!accounts) {
            throw new Error('토큰 사용자가 존재하지 않습니다.');
        }

        // req.accounts 사용자 정보를 저장합니다.
        req.account = accounts;
        res.setHeader('Authorization', `Bearer ${decodedToken}`);
        next();
    } catch (error) {
        if (error.name === 'TokenExpiredError') {
            // 클라이언트가 전달한 엑세스 토큰이 만료된 경우
            const { 'x-info': email } = req.headers;
            const getaccountId = await prisma.account.findFirst({
                where: { email },
            });

            try {
                // 리프레시 토큰을 데이터베이스에서 조회
                const storedToken = await prisma.refreshToken.findFirst({
                    where: { accountId: getaccountId.accountId },
                });

                // 리프레시 토큰이 존재하지 않을 경우
                if (!storedToken) {
                    return res
                        .status(401)
                        .json({ message: '로그인이 필요합니다.' });
                }

                // 리프레시 토큰 검증
                const decodedRefreshToken = jwt.verify(
                    storedToken.token,
                    process.env.SERVER_REFRESH_KEY
                );

                // 검증된 리프레시토큰과 연결된 accountid를 바탕으로 새로운 엑세스 토큰 생성
                const newAccessToken = jwt.sign(
                    {
                        accountId: decodedRefreshToken.accountId,
                        isAdmin: decodedRefreshToken.isAdmin,
                    },
                    process.env.SERVER_ACCESS_KEY,
                    { expiresIn: '1m' }
                );

                // 데이터베이스에서 계정 정보 조회
                const newAccounts = await prisma.account.findFirst({
                    // 검증된 리프레시 토큰과 연계된 accounid로 계정정보 조회
                    where: { accountId: decodedRefreshToken.accountId },
                });

                //조회한 계정정보 할당
                req.account = newAccounts;
                res.setHeader('Authorization', `Bearer ${newAccessToken}`);
                next();
            } catch (refreshError) {
                // 리프레시 토큰이 만료된 경우
                if (refreshError.name === 'TokenExpiredError') {
                    return res.status(401).json({
                        message:
                            '리프레시 토큰이 만료되었습니다. 다시 로그인하세요.',
                    });
                }
                return res.status(401).json({
                    message: '리프레시 토큰 검증 중 오류가 발생했습니다.',
                });
            }
        } else if (error.name === 'JsonWebTokenError') {
            // 예외
            return res.status(401).json({ message: '토큰이 조작되었습니다.' });
        } else {
            return res.status(401).json({ message: '비정상적인 요청입니다.' });
        }
    }
}

보다시피 'Authorization' 헤더를 통해 베어러 토큰을 할당해주고 있다.

한편, 프론트엔드에서는 해당 토큰을 localStorage에 담아주어서 페이지를 벗어나더라도 값이 사라지지 않게끔 작동하고 있었다.

 

코드를 유심히 읽어본 결과, 문제 원인은 생각보다 명확했다. Refresh Token의 발급 조건이 'x-info'에 담긴 'email'이었다는 사실을 우리가 계속 놓치고 있었던 것이다. 실수를 알아차리자마자 곧바로 프론트엔드의 스크립트 파일을 수정하기 시작했다.

    const getAccessToken = () => localStorage.getItem('accessToken');
    // localStorage에서 토큰을 가져올 때 email을 함께 가져온다.
    const email = localStorage.getItem('email');
    console.log('email: ', email);

// ... 중략 ...

const response = await fetch(`${API_BASE}/api/myTeamMember`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    Authorization: `Bearer ${accessToken}`, // 인증 토큰
                    'x-info': email, // 헤더에 email 추가
                },
                body: JSON.stringify(requestBody),
            });

localStorage에서 토큰을 가져올 때 email을 함께 가져와서 fetch를 할 때 요청 헤더에 'x-info': email을 추가해주었다. 그랬더니 우리를 괴롭혔던 많은 에러들이 대부분 해결이 되었다.

 

고생을 오래 했던 것에 비하면 다소 싱겁게 해결되긴 했지만 보안에 대해 깊이 생각해보게 된 중요한 계기였다. 사실은 시간이 더 많았다면 인증방식을 조금 더 강화할 수 있었을 거란 아쉬움이 남기도 한다. 토큰과 이메일을 localStorage에 저장하는 방식이 썩 안전하다고는 여겨지지 않기 때문이다. 이메일을 헤더에 저장해서 직접 전달하기보단, Refresh token을 HttpOnly 쿠키로 전달하는 방식을 취하는 게 더 안전하지 않을까.

 

그러나 아쉬운 결과에는 아쉬운만큼의 교훈이 있다고 생각한다. 혹시라도 다음에 Refresh token을 구현할 기회가 온다면 지금보다 훨씬 더 보안을 고려해서 API를 작성하게 될 테니 말이다.

'JS > TIL(Today I Learned)' 카테고리의 다른 글

2024-12-10 <HTTP와 TCP, 그리고 웹소켓>  (2) 2024.12.10
2024-12-09  (6) 2024.12.09
2024-12-05  (0) 2024.12.05
2024-12-04  (1) 2024.12.04
2024-12-03  (1) 2024.12.03

댓글