Spring Boot - Custom ArgumentResolver

gzip·2023년 6월 2일
3

Spring

목록 보기
2/3
post-thumbnail

Controller에서 요청 헤더의 Authorization 토큰을 Member 타입으로 바인딩하여 받기


이론

@RequestMapping을 사용하는 Controller를 보면

@GetMapping  
public void get(HttpServletRequest req, @PathVariable long id) {}  
  
@PostMapping  
public void post(@RequestBody Body body) {}

우리가 따로 신경쓰지 않아도 HttpServletRequest, @RequestParam, @RequestBody, Model 등 여러가지 타입으로 파라미터를 전달 받을 수 있다.

어떻게 가능할까?


ArgumentResolver

바로 HandlerMethodArgumentResolver(이하 ArgumentResolver) 때문이다.

ArgumentResolver는 컨트롤러가 필요로 하는 파라미터의 값을 생성해 준다. 스프링에서는 30개가 넘는 ArgumentResolver를 기본으로 제공한다고 한다.


HandlerMethodArgumentResolver Interface

public interface HandlerMethodArgumentResolver {  
  
	boolean supportsParameter(MethodParameter parameter);  
	  
	Object resolveArgument(
			MethodParameter parameter, 
			@Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, 
			@Nullable WebDataBinderFactory binderFactory
	) throws Exception;  
}
  • supportsParameter: 이 리졸버가 해당 파라미터를 지원하는지 여부
  • resolveArgument: 파라미터를 가공하는 역할을 한다. 리턴한 Object를 컨트롤러 파라미터로 받을 수 있다.

그렇다면 ArgumentResolver는 어디에서 호출될까?


RequestMappingHandlerAdapter

public class RequestMappingHandlerAdapter
		extends AbstractHandlerMethodAdapter  
		implements BeanFactoryAware, InitializingBean {
	// ...
	
	@Nullable  
	private HandlerMethodArgumentResolverComposite argumentResolvers;
	
	// ...
}

RequestMappingHandlerAdapterargumentResolvers라는 필드를 가지고 있는데 여기에 ArgumentResolver 리스트가 있다.

RequestMappingHandlerAdapterargumentResolvers를 순회하면서 supportsParameter() 메서드의 결과가 true일 경우 순회를 멈추고 resolveArgument()를 호출하여 핸들러가 필요로 하는 인스턴스를 반환 받는다.

RequestMappingHandlerAdapterArgumentResolver에 의해 생성된 인스턴스들을 핸들러를 호출하면서 같이 전달해준다.


코드

이제 ArgumentResolver를 구현하여 컨트롤러에서 요청 헤더의 Authorization 토큰을 Member 타입으로 바인딩하여 아래와 같이 받아보자.

@GetMapping()  
public void getAll(@Auth(Role.ADMIN) Member member) {...}

Entity 관련 코드

Role.java

@Getter  
@AllArgsConstructor  
public enum Role {  
	USER(0), ADMIN(1);  
	  
	private final int code;  
}

Member.java

@Entity  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@Getter  
public class Member {  
  
	@Id  
	@GeneratedValue  
	private Long id;  
	  
	@Column(nullable = false, unique = true)  
	private String uid;  
	  
	@Column(nullable = false)  
	@Enumerated(EnumType.STRING)  
	private Role role;  
	  
	@Builder  
	private Member(@NonNull String uid, @NonNull Role role) {  
		this.uid = uid;  
		this.role = role;  
	}  
}
  • pk는 자동 증가로 하였고, uid는 토큰에서 얻을 수 있는 유니크한 값이라 가정하였다.

ArgumentResolver 관련 코드

Auth.java

package com.example.demo.config.resolver;

import com.example.demo.entity.enums.Role;  
  
import java.lang.annotation.ElementType;  
import java.lang.annotation.Retention;  
import java.lang.annotation.RetentionPolicy;  
import java.lang.annotation.Target;  
  
@Target(ElementType.PARAMETER)  
@Retention(RetentionPolicy.RUNTIME)  
public @interface Auth {  
	Role value() default Role.USER;  
}

AuthArgumentResolver.java

package com.example.demo.config.resolver;  
  
import com.example.demo.dto.TokenInfo;  
import com.example.demo.entity.Member;  
import com.example.demo.entity.enums.Role;  
import com.example.demo.repository.MemberRepository;  
import com.example.demo.service.AuthService;  
import com.example.demo.util.exception.InvalidAuthException;  
import com.example.demo.util.exception.InvalidTokenException;  
import lombok.RequiredArgsConstructor;  
import lombok.extern.log4j.Log4j2;  
import org.springframework.core.MethodParameter;  
import org.springframework.lang.Nullable;  
import org.springframework.stereotype.Component;  
import org.springframework.util.StringUtils;  
import org.springframework.web.bind.support.WebDataBinderFactory;  
import org.springframework.web.context.request.NativeWebRequest;  
import org.springframework.web.method.support.HandlerMethodArgumentResolver;  
import org.springframework.web.method.support.ModelAndViewContainer;  
  
@Log4j2  
@Component  
@RequiredArgsConstructor  
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {  
  
	private final AuthService authService;  
	private final MemberRepository memberRepo;  
	  
	@Override  
	public boolean supportsParameter(MethodParameter parameter) {  
		boolean hasAuthAnnotation =  
				parameter.hasParameterAnnotation(Auth.class);  
		boolean hasMemberType =  
				Member.class.isAssignableFrom(parameter.getParameterType());  
		  
		log.debug("hasAuthAnnotation = {}, hasMemberType = {}", hasAuthAnnotation, hasMemberType);  
		return hasAuthAnnotation && hasMemberType;
	}  
	  
	@Nullable  
	@Override  
	public Object resolveArgument(  
			MethodParameter parameter,  
			@Nullable ModelAndViewContainer mavContainer,  
			NativeWebRequest webRequest,  
			@Nullable WebDataBinderFactory binderFactory  
	) {  
		// token 얻기  
		String token = webRequest.getHeader("Authorization");  
		if (!StringUtils.hasText(token)) {  
			throw new InvalidTokenException("'token' must not be empty");  
		}  
		  
		// Auth 어노테이션의 role 얻기  
		Auth authAnnotation = parameter.getParameterAnnotation(Auth.class);  
		if (authAnnotation == null) {  
			throw new NullPointerException("'Auth annotation' must not be null");  
		}  
		Role role = authAnnotation.value();  
		  
		// 토큰 검증 받기  
		TokenInfo tokenInfo = authService.verifyToken(token);  
		  
		// member 조회  
		Member member = memberRepo.findByUid(tokenInfo.getUid())  
				.orElseThrow(() -> new InvalidTokenException("잘못된 토큰입니다"));  
		  
		// role 권한 체크  
		if (member.getRole().getCode() < role.getCode()) {  
			throw new InvalidAuthException("권한이 없습니다");  
		}  
		  
		return member;  
	}  
}
  • supportsParameter() 메서드는 Auth 어노테이션을 가지고 있으면서 Member 타입일 경우에만 true를 리턴한다.
  • resolveArgument() 메서드는 요청에서 Authorization 토큰을 찾고, 파라미터에서 Role을 찾아 토큰 검증, member 조회, role 권한을 체크하고 조회한 member를 리턴한다.
  • (InvalidTokenException는 401을 리턴, InvalidAuthException는 403을 리턴하는 커스텀 exception이다.)

토큰 검증 관련 코드

이 부분은 데모용으로 간단히 코딩하였다. 각자 사용하는 인증 서비스에 맞게 구현하면 된다. (ex. firebase를 사용하면 firebase api를 호출... 등등)

TokenInfo.java

package com.example.demo.dto;  
  
import lombok.AllArgsConstructor;  
import lombok.Getter;  
  
@Getter  
@AllArgsConstructor  
public class TokenInfo {  
	private String token;  
	private String uid;  
	// ...  
}

AuthService.java

package com.example.demo.service;  
  
import com.example.demo.dto.TokenInfo;  
  
public interface AuthService {  
	TokenInfo verifyToken(String token);  
}

MyAuthServiceImpl.java

package com.example.demo.service.impl;  
  
import com.example.demo.dto.TokenInfo;  
import com.example.demo.service.AuthService;  
import com.example.demo.util.exception.InvalidTokenException;  
import lombok.extern.log4j.Log4j2;  
import org.springframework.stereotype.Service;  
  
@Log4j2  
@Service  
public class MyAuthServiceImpl implements AuthService {  
  
	@Override  
	public TokenInfo verifyToken(String token) {  
		log.debug("verifyToken - token = {}", token);  
		  
		TokenInfo tokenInfo;  
		  
		try {  
			// token 검증 로직  
			String uid = token.replace("Bearer ", "");  
			tokenInfo = new TokenInfo(token, uid);  
		  
		} catch (Exception e) {  
			throw new InvalidTokenException("잘못된 토큰입니다");  
		}  
		  
		return tokenInfo;  
	}  
}

Controller

MemberController

package com.example.demo.controller;  
  
import com.example.demo.config.resolver.Auth;  
import com.example.demo.dto.TokenInfo;  
import com.example.demo.entity.Member;  
import com.example.demo.entity.enums.Role;  
import com.example.demo.util.exception.InvalidAuthException;  
import lombok.extern.log4j.Log4j2;  
import org.springframework.web.bind.annotation.*;  
  
@Log4j2  
@RestController  
@RequestMapping("members")  
public class MemberController {  
  
	@GetMapping()  
	public void getAll(@Auth(Role.ADMIN) Member member) {  
		log.debug("getAll - member = {}", member);  
		  
		// service.getAll() ...  
	}  
	  
	@GetMapping("{id}")  
	public void getById(@PathVariable long id, @Auth Member member) {  
		log.debug("getById - id = {}, member = {}", id, member);  
		  
		if (member.getRole() == Role.USER && member.getId() != id) {  
			throw new InvalidAuthException("잘못된 접근입니다");  
		}  
		  
		// service.getById(id) ...  
	}  
}
  • getAll() 메서드는 admin만 호출 가능
  • getById() 메서드는 user, admin 호출 가능, user는 다른 멤버 조회 불가

테스트

코드가 길어서 잘라서 적었다.

getAll() 테스트

@Nested  
@DisplayName("getAll() - Test")  
class getAll_Test {  
  
	@Test  
	@DisplayName("성공: Role.ADMIN")  
	void 성공_Role_ADMIN() throws Exception {  
		// given  
		Member mockMember = mockMembers.stream()  
				.filter(m -> m.getRole() == Role.ADMIN)  
				.findAny()  
				.get();  
		String token = "Bearer " + mockMember.getUid();
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
				.get(BASE_URL)  
				.header("Authorization", token));  
		  
		// then  
		actions.andExpect(status().isOk());  
	}  
	  
	@Test  
	@DisplayName("실패: Role.USER - 권한 없음(403)")  
	void 실패_Role_USER_권한_없음403() throws Exception { 
		// given  
		Member mockMember = mockMembers.stream()  
				.filter(m -> m.getRole() == Role.USER)  
				.findAny()  
				.get();  
		String token = "Bearer " + mockMember.getUid();
		  
		int expectedStatus = HttpStatus.FORBIDDEN.value();  
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
				.get(BASE_URL)  
				.header("Authorization", token));  
		  
		// then  
		actions  
				.andExpect(status().is(expectedStatus))  
				.andExpect(r -> assertThat(r.getResolvedException())  
						.isExactlyInstanceOf(InvalidAuthException.class));  
	}  
	  
	@Test  
	@DisplayName("실패: 잘못된 토큰(401)")  
	void 실패_잘못된_토큰401() throws Exception {  
		// given  
		String uid = "any";  
		String token = "Bearer " + uid;  
		  
		int expectedStatus = HttpStatus.UNAUTHORIZED.value();  
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
				.get(BASE_URL)  
				.header("Authorization", token));  
		  
		// then  
		actions  
				.andExpect(status().is(expectedStatus))  
				.andExpect(r -> assertThat(r.getResolvedException())  
						.isExactlyInstanceOf(InvalidTokenException.class));  
	}  
}
  • admin일 경우 성공
  • user일 경우 실패
  • 잘못된 토큰 검증 실패

getById() 테스트

@Nested  
@DisplayName("getById() - Test")  
class getByUid_Test {  
	@Test  
	@DisplayName("성공: Role.USER")  
	void 성공_Role_USER() throws Exception {  
		// given  
		Member mockMember = mockMembers.stream()
				.filter(m -> m.getRole() == Role.USER)
				.findAny()
				.get();  
		String token = "Bearer " + mockMember.getUid();  
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
				.get(BASE_URL + "/" + mockMember.getId())  
				.header("Authorization", token));  
		  
		// then  
		actions.andExpect(status().isOk());  
	}  
	  
	@Test  
	@DisplayName("성공: Role.ADMIN")  
	void 성공_Role_ADMIN() throws Exception {  
		// given  
		Member mockMember = mockMembers.stream()
				.filter(m -> m.getRole() == Role.ADMIN)
				.findAny()
				.get();  
		String token = "Bearer " + mockMember.getUid();  
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
			.get(BASE_URL + "/" + mockMember.getId())  
			.header("Authorization", token));  
		  
		// then  
		actions.andExpect(status().isOk());  
	}  
	  
	@Test  
	@DisplayName("실패: Role.USER - 다른 멤버 조회 권한 없음(403)")  
	void 실패_Role_USER_다른_멤버_조회_권한_없음403() throws Exception {  
		// given  
		Member mockMember = mockMembers.stream()
				.filter(m -> m.getRole() == Role.USER)
				.findAny()
				.get();  
		String token = "Bearer " + mockMember.getUid();  
		long memberId = 999;  
		  
		int expectedStatus = HttpStatus.FORBIDDEN.value();  
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
				.get(BASE_URL + "/" + memberId)  
				.header("Authorization", token));  
		  
		// then  
		actions  
				.andExpect(status().is(expectedStatus))  
				.andExpect(r -> assertThat(r.getResolvedException())  
						.isExactlyInstanceOf(InvalidAuthException.class));  
	}  
	  
	@Test  
	@DisplayName("성공: Role.ADMIN - 다른 멤버 조회")  
	void 성공_Role_ADMIN_다른_멤버_조회() throws Exception {  
		// given  
		Member mockMember = mockMembers.stream()
				.filter(m -> m.getRole() == Role.ADMIN)
				.findAny()
				.get();  
		String token = "Bearer " + mockMember.getUid();  
		long memberId = 999;  
		  
		int expectedStatus = HttpStatus.FORBIDDEN.value();  
		  
		// when  
		ResultActions actions = mockMvc.perform(MockMvcRequestBuilders  
				.get(BASE_URL + "/" + memberId)  
				.header("Authorization", token));  
		  
		// then  
		actions.andExpect(status().isOk());  
	}  
}
  • user와 admin 모두 자신의 멤버 조회 가능
  • user는 다른 멤버 조회 실패
  • admin은 다른 멤버 조회 성공

0개의 댓글