[프로젝트]JWT 인가(With Interceptor)

Inung_92·2023년 4월 10일
1

프로젝트

목록 보기
9/9
post-thumbnail

지난번 게시글에서 JWT를 이용한 로그인 기능 구현을 통해 인증(Authentication)에 대해서 다뤄보았다. 이번에는 Interceptor와 JWT를 이용한 인가(Authorization)에 대하여 다뤄보려한다.

인가(Authorization)란?

📖인증된 사용자가 어떠한 작업을 수행할 수 있는 권한을 가지고 있는지 검증하는 작업. 즉, 특정 리소스에 대한 액세스 권한이 있는지를 확인하는 작업이다.

인증과 인가, 우리가 자주 접하는 단어들이지만 조금은 헷갈리는 단어들이이게 사전정 적의를 통해 차이를 알아보았다.

  • 인증(Authentication) : 사용자가 자신의 신원을 증명하고 인증하는 프로세스(예: 로그인 등)
  • 인가(Authorization) : 인증된 사용자가 특정 리로스에 대한 액세스 권한이 있는지를 확인하는 작업(예: 로그인, 본인확인 여부 등)

JWT는 인증 수행한 사용자에 한하여 인증된 정보를 확인하고 검증하여 인가를 통해 사용자가 특정 리소스에 접근하여 작업을 수행할 수 있도록 해주는 것이다.

JWT와 Interceptor

JWT에 대해서는 이전게시글을 통해 알아보도록하자. 이번 게시글에서는 Interceptor에 대하여 알아볼 예정이다.

Interceptor란?

📖HTTP 요청을 처리 중에 요청 및 응답에 대한 처리를 제어하고 변경할 수 있는 방법을 제공하는 Spring MVC의 기능 중 하나

쉽게 설명하여 Interceptor는 컨트롤러가 요청을 처리하기 전, 후로 하여 보안, 로깅, 권한 등의 기능을 처리하는 기능이라고 보면 된다.

Interceptor를 사용하기 위해서는 HandlerInterceptor 인터페이스를 구현해야한다. HandlerInterceptor가 지원하는 메소드에 대하여 알아보자,

⚡️ Interceptor의 메소드

  • preHandle() : 컨트롤러가 요청을 처리하기 전에 수행하며, 반환 값은 논리값으로 반환한다. false 반환 시 요청의 진행을 중단한다.
  • postHandle() : 컨트롤러가 요청을 처리한 후에 수행하며, ModelAndView 객체를 추가하거나 데이터를 추가할 수 있다.
  • afterComletion() : 요청 처리가 완료된 후에 호출하며, 컨트롤러와 인터셉터의 예외가 발생할 때에도 호출된다.

위 처럼 시점에 따라 호출하는 메소드를 달리 적용하여 요청 및 응답 처리에 대하여 제어 또는 변경을 할 수 있다. 인가의 경우 특정 리소스의 접근하기 전에 수행되어야하므로 preHandle()을 재정의하여 기능을 구현하였다.

⚡️ Interceptor의 장점

Interceptor는 위에서 설명한 것처럼 보안, 로깅, 권한 등의 기능을 처리한다. 로그인을 완료한 사용자가 결제, 예약 등등의 서비스를 이용하기 위해서는 로그인된 사용자의 정보가 인가를 통해 지속적인 확인 및 검증이 필요하다. 이러한 부분을 한번의 코드 작성을 통해 가능토록 하는 것이 Interceptor인 것이다.

  • 요청과 응답에 대한 처리를 중앙에서 관리 가능
  • 보안 등의 처리를 담당하는 코드를 중복작성하지 않아도 됨
  • Interceptor chain을 통해 여러개의 Interceptor를 통해 기능 모듈화 및 재사용성을 높일 수 있음

여기까지 개념적인 설명을 마치고 본격적으로 인가 기능에 대한 구현을 해보자.


기능구현

⚡️ 시나리오

  • 로그인 인증이 완료된 사용자가 예약, 결제 등에 대한 기능에 접근
  • 서버로 해당 사용자의 JWT가 전달되며 이를 Interceptor를 통해 컨트롤러가 요청을 처리하기 전 권한 검증
  • 권한이 검증되었을 경우 회원 정보를 request 객체에 포함하여 컨트롤러가 요청 수행
  • 권한이 검증되지 않을 경우 false를 반환하며 접근 불허

전체적인 흐름도는 아래의 그림과 같다.

그림에서 보이는 것처럼 Interceptor는 Filter와는 다르게 dispatcherServlet이 요청을 접수한 뒤 처리하기 이전에 개입하여 요청에 대한 부가적인 기능을 수행한다. 또한, 이 시점은 위의 제시한 3가지 메소드를 통해 지정할 수 있다.

⚡️ Interceptor 설정

스프링 레거시 환경에서의 설정으로 어노테이션을 활용하지 않고, xml을 통한 설정에 대하하여 알아보도록 하자.

🖥️ client-context.xml

프로젝트 특성 상 관리자와 클라이언트의 서블릿을 분리하여 개발을 진행하고 있다. 이러한 특성으로 인해 클라이언트의 접근 시에 적용할 Interceptor를 아래와 같이 선언해준다.

<mvc:interceptors>
  <mvc:interceptor>
    <mvc:mapping path="/token/**" />
    <!-- 인터셉터 대상에서 제외할 url -->
    <mvc:exclude-mapping path="/join/**" />
    <mvc:exclude-mapping path="/login/**" />
    
    <beans:bean id="jwtInterceptor"
                class="com.edu.surfing.interceptor.JwtInterceptor" />
  </mvc:interceptor>
</mvc:interceptors>
  • mapping : 해당 URL로 요청이 들어온 경우에만 Interceptor가 호출되도록 설정
  • exclude-mapping : 특정 URL을 지정하여 Interceptor를 거치지 않고 요청처리

여기서 특정 URL을 지정하는 목적은 로그인(인증)을 하지 않은 사용자에게 권한 검증을 수행하려하면 당연히 권한이 없을 것이다. 그렇기 때문에 특정 URL을 지정하여 해당 작업을 분리시켜주어야한다. 물론 URL만으로도 분리가 가능할 수 있겠지만 정확히 구분짓기 위하여 선언해주었다.

다음은 Interceptor 클래스를 선언해주도록 하자.

🖥️ JwtInterceptor

@RequiredArgsConstructor
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
	private final JwtProvider jwtProvider;

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception, CustomException {
	}

	@Override
	public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
	}

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
			throws Exception {
	}

}

위에서 설명한대로 3가지의 메소드를 확인할 수 있다. 나는 preHandle()을 재정의하여 인가처리를 구현할 예정이다.

🖥️ preHandle()

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception, CustomException {
	//React와의 연동으로 인한 CORS 정책 판단 조건
	if(HttpMethod.OPTIONS.matches(request.getMethod())) {
    	return true;
    }
    
    // 인가 요청 여부확인
    String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
    // JWT 여부 확인
    String token = authorization.replaceAll("Bearer ", "");
    
    // token의 값이 정상적인지 확인
    if (token != null && token.length() > 10) {
    	log.debug("토큰 상태:: " + token);
    	
        // token 유효성 검증
    	if (jwtProvider.vaildToken(token)) {
    		ObjectMapper objectMapper = new ObjectMapper();
            
            // Payload 내 Member 객체 정보 추출
            String member = objectMapper.writeValueAsString(jwtProvider.getMemberInfo(token).get("member"));
            Member accessMember = objectMapper.readValue(member, Member.class);
			
            // 추출한 정보 request 객체에 적재
            request.setAttribute("member", accessMember);
            return true;
        }
    } else {
    	throw new CustomException(ErrorCode.VALID_MEMBER);
    }
    return false;
}

요청이 발생했을 때 제일 먼저 확인하는 것은 해당 요청이 인가 요청인지에 대해서 판단하기 위해 Header의 Authorization 여부를 가장 먼저 판단한다.
이후 JWT가 정상적인지 분석을 통해 인가 처리를 완료하고 승인되면 사용자 정보를 다음 요청을 처리할 객체에게 전달하여 주는 형식으로 기능을 구현하였다.

그렇다면 JWT는 어떤 로직으로 구현되어 있는지 확인해보자.

🖥️ JwtProvider

@Slf4j
@Setter
@PropertySource("/WEB-INF/config/api.properties")
@Component
public class JwtProvider {
	/* 서명에 사용할 secretKey 설정은 xml에서 property로 직접등록 */
	@Value(value = "${jwt.secret}")
	private String secretKey;

	/*
	 * 토큰 생성 메소드 jwt에 저장할 회원정보를 파라미터로 전달
	 */
	public String createToken(Member member) {
		Date now = new Date(System.currentTimeMillis());
		Long expiration = 1000 * 60 * 60L;
		
		return Jwts.builder().setHeaderParam("typ", "JWT") // 토큰 타입 지정
				.setSubject("accessToken") // 토큰 제목
				.setIssuedAt(now) // 발급시간
				.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 만료시간
				.claim("member", member) // 회원 아이디 저장
				.signWith(SignatureAlgorithm.HS256, secretKey.getBytes())
				.compact();
	}

	/* 토큰 해석 메소드 */
	public Claims getMemberInfo(String token) throws CustomException {
		Jws<Claims> claims = Jwts.parser()
				.setSigningKey(secretKey.getBytes())
				.parseClaimsJws(token);
		return claims.getBody();
	}

	/* 유효성 확인 */
	public boolean vaildToken(String token) throws CustomException {
		try {
			Claims claims = Jwts.parser()
					.setSigningKey(secretKey.getBytes())
					.parseClaimsJws(token) //토큰 파싱
					.getBody();
			return true;  //유효하다면 true 반환
		} catch (MalformedJwtException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_SIGNATURE, e);
		} catch (ExpiredJwtException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_EXPIRED, e);
		} catch (UnsupportedJwtException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_UNSUPPORTED, e);
		} catch (SignatureException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_SIGNATURE, e);
		}
	}

}

JWT를 발급한 이후 인증된 사용자가 요청을 보낼 시 먼저 vaildToken()을 통해 JWT의 유효성을 검증하고, 유효성 검증을 마친 사용자는 getMemberInfo()를 통하여 인증된 사용자 정보를 요청처리에 적합하도록 전달해주는 방식으로 로직이 구성되어있다.

이렇게 간단하지만 살짝은 머리가 아팠던 Interceptor를 활용한 인가기능을 구현하고나니 모든 요청에 대한 인가 기능 코드를 작성할 필요없이 단 한번의 코드 작성을 통해 자동으로 처리되는 기능이 구현된 것이다.

추가적으로 보완해야할 점은 JWT가 만료되지 않도록 접속을 유지한 동안에 요청이 들어오면 리프레쉬 토큰을 발급하여 인증을 연장시키고 유지하는 것이다. 이 부분은 차차 다뤄보도록 하자.


오류처리

기능을 구현하면서 조금 애를 먹었던 부분은 바로 아래 코드 부분이다.

🖥️ getMemberInfo()

/* 토큰 해석 메소드 */
public Claims getMemberInfo(String token) throws CustomException {
	Jws<Claims> claims = Jwts.parser()
	.setSigningKey(secretKey.getBytes())
	.parseClaimsJws(token);
    
	return claims.getBody();
}

💻 JwtInterceptor

// token 유효성 검증
if (jwtProvider.vaildToken(token)) {
	ObjectMapper objectMapper = new ObjectMapper();
    
    // Payload 내 Member 객체 정보 추출
    String member = objectMapper.writeValueAsString(jwtProvider.getMemberInfo(token).get("member"));
    Member accessMember = objectMapper.readValue(member, Member.class);
    
    // 추출한 정보 request 객체에 적재
    request.setAttribute("member", accessMember);
    return true;
}

이 부분에서 인가처리가 완료된 사용자의 정보를 추출하여 객체로 반환해야하는데 LinkedHashMap의 Parse오류가 발생하는 것이었다. 원인을 분석해보니 다음과 같았다.

  • Claim에 저장된 객체는 JSON 형식으로 인코딩되어 저장되기 때문에 String형으로 저장됨.
  • Claim 내부에 저장된 객체를 get()을 통해 접근하게되면 HashMap으로 객체가 반환됨.
  • LinkedHashMap으로 반환된 객체를 사용자지정 자료형으로 캐스팅을 하는 것이 오류가 발생함.(String을 다른 자료형으로 캐스팅하려고하니 당연한 이치였음)

이러한 부분을 정확히 확인하지 않고 계속해서 캐스팅을 하려고하니 오류가 발생하던 거였음. 이러한 부분은 다음과 같이 해결하면된다.

  • ObjectMapper를 통해서 String형으로 캐스팅하여 추출한다.
  • String형으로 추출된 결과를 ObjectMapper를 통해 해당 자료형으로 캐스팅해주면됨.

아주 간단하게 해결될 오류를 꽤나 오래 붙잡고 있었던 것 같았다. 매번 느끼지만 동작원리를 이해하지 못하면 간단한 오류의 원인을 분석하는 것에 시간을 너무 많이 낭비하게 되는 것 같다.😂

이외에도 발생했던 오류들을 나열하면 다음과 같다.

  • 시그니처 오류는 secretKey를 동일한 형식(getBytes())으로 작성해야한다. 아래 코드를 보자.
//메소드 1
Jws<Claims> claims = Jwts.parser()
	.setSigningKey(secretKey.getBytes())
	.parseClaimsJws(token);
    
//메소드 2
Jws<Claims> claims = Jwts.parser()
	.setSigningKey(secretKey)
	.parseClaimsJws(token);

이러한 형태로 작성을 해주게되면 Token을 파싱하는 과정에서 정상적인 동작이 이루어지지 않고 시그니처 오류가 계속적으로 발생한다. 이런 부분을 놓치지 말고 확인하도록 하자.

  • 요청을 보내는 경우 헤더는 "Authorization": "Bearer " + accessToken 형식으로로 넘겨줘야하며, 요청을 받는 서버에서는 " "을 기준으로 subString()으로 분리하여 사용해야함

마무리

JWT를 통한 인증을 먼저 구현하고, 인증된 사용자가 특정 리소스에 접근 할 수 있도록 인가를 구현해보았다.

인증과 인가의 차이에 대해서 곰곰히 생각해볼 수 있었고, Interceptor가 어떠한 원리로 동작되어지는지 알 수 있었던 기능 구현이라고 생각한다.

어떤 기능을 구현하던지간에 사용된 라이브러리 등에 대한 동작원리를 제대로 파악하고 흐름을 이해한다면 오류가 발생하더라도 원인을 빠르게 찾을 수 있지만 이러한 부분을 신경쓰지 않고 작동만하는 기능을 구현하게 된다면 오류가 발생할 경우 해결은 말도 못할테고, 원인을 찾는 것도 힘들 것이란 생각을 하게되었다.

다음에는 스프링 시큐리티를 이용한 인증 및 인가 기능을 구현을 시도해 봐야겠다.

그럼 이만.👊🏽

profile
서핑하는 개발자🏄🏽

0개의 댓글