[2024.01.25] 토큰(JWT)에 대한 주체 검증

아스라이지새는달·2024년 2월 14일
2
post-thumbnail

프로젝트를 진행하던 중 문득 의문점이 하나 들었다. A라는 멤버가 발급받은 토큰으로 B라는 멤버에 대한 API를 사용할 수 있을까? 라는 의문이었다. 즉 기존 코드로 토큰에 대한 주체를 검증을 할 수 있는가에 대한 의문이었다.
오늘은 이 의문점에 대해 테스트 해보고 검증하는 과정에 대한 포스팅이다.

🏞️ 의문점

지금 진행하고 있는 프로젝트의 API 중에는 멤버의 id(DB에서 순차적으로 부여하는 id)를 URI의 변수로 사용하는(@PathVariable를 사용하는) API가 몇 개 있다.

이 API를 사용하다가 문득 의문이 하나 생겼다. A라는 멤버가 발급받은 토큰을 B라는 멤버에 대한 API 요청의 토큰으로 사용할 수 있을까?였다. 자세히는 id가 1이라는 멤버가 로그인을 통해 토큰을 발급 받고 이 토큰을 id가 2인 멤버에 대한 API에 요청을 보낼 때 싣는 토큰으로 사용할 수 있는지 궁금해진 것이다. 바로 테스트 해보았다.

테스트

우선 테스트를 위해 DB에 데이터를 다음과 넣어주었다.

idemailnamepasswordrolestudend_id
37test1@gmail.comtest1test!!ROLE_STUDENT20241111
38test2@gmail.comtest2test@@ROLE_STUDENT20242222

그 후 id가 37인 멤버로 로그인을 하여 토큰을 발급받는다.

발급받은 토큰을 가지고 우선 id가 37인 멤버, 즉 본인에 대한 API에 요청을 보내본다.
간단하게 멤버의 이름을 응답하는 API에 요청을 보내보았다.

정상적으로 test1이라는 이름을 응답하는 것을 확인할 수 있다.

이번에는 id가 38인 멤버에 대한 API에 요청을 보내보았다.

id가 38인 멤버의 이름 test2를 응답하였다.

문제 인식

사실 이 동작은 올바른 동작이 아니다.
만약 어떤 사용자가 API를 알아내 id 숫자를 조작하고 이를 요청으로 보낸다면 사용자의 정보를 탈취할 수 있게 된다. 사용자가 개발자의 의도대로 사용한다면 참 좋겠지만 세상은 시나리오 대로 흘러가지 않기에 항상 대비를 해야한다.

따라서 이를 문제로 삼고 이 문제를 해결해 보려 한다.
해결하기 이전에 우선 이 문제가 보편적인 문제인지 구글링을 통해 알아보았다.

Stack Overflow1

Stack Overflow2

사례가 많지는 않지만 비슷한 상황을 겪고 있는 사람들을 보았다. 기본적으로 Spring Security에서 이를 검증해준다고 생각했는데 아니었다. 사례들을 보고 좀 더 생각을 해보았다. Spring Security는 인증과 인가를 수행하는데 인증 단계에서는 DB를 통해 사용자 본인이 맞는지 확인을 하고 인가 단계에서는 인증 단계에서 인증된 사용자가 요청한 리소스에 접근이 가능한지를 결정한다. 이 때 단순히 리소스 접근 권한을 확인하는 것이지 그 리소스가 본인에 대한 리소스인지는 검증을 하지 않는 것이라고 생각했다. 즉 이 부분은 검증 로직을 작성해 검증을 해야 하는 것이다.

이 생각이 맞는 생각인지는 아직까지도 잘 모르겠다. 좀 더 공부해야겠다.


🔨 해결책

해결책은 위에 언급한 대로 검증하는 로직을 직접 작성하면 된다.

생각한 시나리오는 이렇다.

  1. 멤버의 id를 URI의 변수로 받는 메서드에서 추가적으로 HttpServletRequest를 받는다.
    이 HttpServletRequest에 담긴 토큰을 활용할 것이다.

  2. Request에 담긴 토큰을 꺼낸다.

  3. JWT의 Payload에는 Subject가, 내 경우엔 멤버의 학번이 담겨있다. 이 학번과 id로 찾은 멤버의 학번을 비교한다.

  4. 일치한다면 토큰의 주체와 요청을 보낸 주체가 같은 것을 의미한다. 일치하지 않는다면 토큰과 요청을 보낸 주체가 서로 다르기 때문에 Exception을 던진다.

시나리오 대로 차근차근 코드를 작성해보자.

Controller

우선 로직을 담당하는 Service단의 메서드에 HttpServletRequest를 넘겨주기 위해 Controller단에서 HttpServletRequest를 받도록 코드를 수정한다.
또한 Service단에서 Exception이 발생하면 throw할 예정이기에 Controller단에서 try-catch문으로 작성해준다.
Exception을 catch하였으면 로그를 찍고 400 HTTP Status Code와 함께 "Bad Request"라는 Response Body를 보내준다.

// controller/MemberController.java

@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {

    private final MemberService memberService;

    @ApiOperation(value = "로그인 정보", notes = "해당 멤버의 이름 가져오기")
    @GetMapping("/{id}") // id : member-id
    private ResponseEntity<?> getUserById(@PathVariable Long id, HttpServletRequest request) throws Exception {
        try {
            return new ResponseEntity<>(memberService.getUserNameById(id, request), HttpStatus.OK);
        } catch(Exception e) {
            log.error("Exception [Err_Msg]: {}", e.getMessage());
            log.error("Exception [Err_Where]: {}", e.getStackTrace()[0]);
            return new ResponseEntity<>("Bad Request", HttpStatus.BAD_REQUEST);
        }
    }
}

여담으로 이전에는 log를 출력하는 방법으로 printStackTrace()를 사용했지만 printStackTrace()는 cost가 비싸기도 하고 필요하지 않은 로그도 우다다 찍히기 때문에 @Slf4j 어노테이션을 사용하기로 하였다.

JwtTokenProvider

TokenProvider에서는 Service단에서 사용할 메서드들을 정의해준다.
resolveToken 메서드는 헤더로부터 토큰을 추출하는 메서드이고 parseJWT 메서드는 토큰의 Payload를 추출하는 메서드이다.

// jwt/JwtTokenProvider.java

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    private String secretKey = "";
    
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }
    
    public Claims parseJWT(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }
}

secretKey는 보안을 위해 생략하였다.

Service

Service단에서는 우선 검증 로직을 담당하는 코드를 작성한다.

해당 메서드는 Member와 HttpServletRequest을 인자로 받는다. id를 받아도 되지만 API의 로직을 담당하는 메서드에서도 DB 조회를 하고 검증 메서드에서도 DB 조회를 하면 필요없는 연산을 하기에 Member 객체를 인자로 전달하였다.

JwtTokenProvider에 선언된 resolveToken 메서드와 parseJWT 메서드를 통해 토큰의 Payload 부분을 추출해낸다.
추출한 Payload에서 getSubject() 메서드를 통해 학번(studentId)을 추출하고 이를 인자로 전달받은 Member의 학번과 비교를 한다. 일치한다면 true를 일치하지 않는다면 false를 반환한다.

// service/MemberServiceImpl.java

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final JwtTokenProvider jwtTokenProvider;

    public Boolean validateTokenMatch(Member member, HttpServletRequest request) {
        try {
            String token = jwtTokenProvider.resolveToken(request);

            if(!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
                return false;
            } else {
                token = token.split(" ")[1].trim();
            }

            Claims claims = jwtTokenProvider.parseJWT(token);

            // return true if match, return false if does not match
            return Objects.equals(claims.getSubject(), String.valueOf(member.getStudentId()));
        } catch(Exception e) {
            return false;
        }
    }
}

그 후 검증 메서드를 API의 로직을 담당하는 메서드에 추가해주면 된다.

// service/MemberServiceImpl.java

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public GetUserDto getUserNameById(Long id, HttpServletRequest request) throws Exception {
        Member member = memberRepository.findByid(id);

        if(!validateTokenMatch(member, request)) {
            throw new Exception("Invalid Token");
        }

        GetUserDto newDto = new GetUserDto();
        newDto.setUserName(member.getName());

        return newDto;
    }
}

🧪 테스트

검증 로직 작성을 완료하였으니 이제 올바르게 동작하는지 테스트해 볼 차례이다.

우선 위에서 진행했던 테스트처럼 id가 37인 멤버로 로그인을 하여 토큰을 발급받는다.

발급받은 토큰으로 id가 38인 멤버에 대한 API에 요청을 보내본다.

의도한 대로 400 HTTP Status Code와 함께 "Bad Request"가 넘어온다.

또한 IntelliJ에도 의도한 대로 로그가 찍히는 것을 보아 성공적으로 검증 로직을 작성하였다.


🔍 Reference

https://stackoverflow.com/questions/76832449/how-can-i-set-the-jwt-token-to-be-unique-for-each-user

https://stackoverflow.com/questions/60451288/can-single-jwt-token-can-be-accessed-by-all-users-in-rest-api-call

https://parkstate.tistory.com/31

https://velog.io/@hyoreal51/Spring-HTTP-Header

profile
웹 백엔드 개발자가 되는 그날까지

0개의 댓글