장바구니 미션 2단계에서 인증에 관한 기능을 추가하는 요구사항이 있었다.
인증에 대한 요구사항은 Basic
인증 방식이고, 해당 인증을 구현하려면 클라이언트의 요청의 Authorization
헤더에 있는 BASE64
로 인코딩된 계정 정보를 기반으로 인증을 수행한다.
따라서 인증이 필요한 부분에서 컨트롤러의 핸들러 메소드마다 Authorization
헤더를 꺼낸 뒤 BASE64
문자열을 디코딩한 뒤 계정의 정보를 확인해야 한다.
인증이 필요한 URL마다 중복이 생기게 되는데 해당 중복되는 부분을 어떻게 없앨 수 있을까?
``
장바구니와 관련된 기능에서 요청에 있는 Authorization
헤더에 있는 계정 정보 값을 조회하고, 해당 계정의 장바구니에 상품을 등록해야 한다.
이때 컨트롤러의 코드는 다음과 같다.
@RestController
@RequestMapping("/api/cart")
public class CartApiController {
private static final String AUTHORIZATION_HEADER = "authorization";
private final CartService cartService;
private final BasicAuthProvider basicAuthProvider;
public CartApiController(CartService cartService, BasicAuthProvider basicAuthProvider) {
this.cartService = cartService;
this.basicAuthProvider = basicAuthProvider;
}
@PostMapping("/{productId}")
public ResponseEntity<Response> addProductToCart(@PathVariable Long productId, HttpServletRequest request) {
Long memberId = basicAuthProvider.resolveMemberId(request.getHeader(AUTHORIZATION_HEADER));
cartService.addToCart(memberId, productId);
return ResponseEntity.ok()
.body(SimpleResponse.ok("장바구니에 상품이 담겼습니다."));
}
@GetMapping
public ResponseEntity<Response> findAllProductsByMemberId(HttpServletRequest request) {
Long memberId = basicAuthProvider.resolveMemberId(request.getHeader(AUTHORIZATION_HEADER));
List<CartProductDto> allProducts = cartService.findAllCartProducts(memberId);
return ResponseEntity.ok()
.body(ResultResponse.ok(allProducts.size() + "개의 상품이 조회되었습니다.", allProducts));
}
@DeleteMapping("/{cartId}")
public ResponseEntity<Response> deleteProductToCart(@PathVariable Long cartId, HttpServletRequest request) {
Long memberId = basicAuthProvider.resolveMemberId(request.getHeader(AUTHORIZATION_HEADER));
cartService.deleteProduct(memberId, cartId);
return ResponseEntity.ok()
.body(SimpleResponse.ok("장바구니에 상품이 삭제되었습니다."));
}
}
핸들러 메소드는 3개 밖에 없지만, 중복되는 부분이 있다.
매개변수로 HttpServletRequest
를 받고, Request
에 있는 authorization
헤더의 값을 조회하여 회원의 ID를 조회하는 부분이다.
BasicAuthProvider
라는 객체로 인증 작업을 외부로부터 분리하여 중복은 제거했지만, 인증이 필요한 URL마다 HttpServletRequest
객체를 받아야 하는 부분은 변함이 없다.
따라서 다음과 같이 코드를 작성하더라도 근본적인 해결은 불가능하다.
private Long resolveMemberId(HttpServletRequest request) {
String authInfo = request.getHeader(AUTHORIZATION_HEADER);
return basicAuthProvider.resolveMemberId(authInfo);
}
또한, 추후 장바구니 말고 다른 URL에 인증이 필요한 경우가 생긴다면 중복되는 코드가 늘어나고, 유지보수의 비용이 증가할 것이다.
그렇다면 어떻게 이 부분을 개선할 수 있을까?
컨트롤러로 요청이 넘어올 때 DispatcherServlet을 통해 요청이 넘어오게 되는데, 이때 중간 과정을 처리하는 객체를 정의하여 인증에 대한 책임을 외부로 분리를 할 수 있다.
중간에 처리하는 방법은 여러 가지가 있지만, Controller의 매개변수로 넘어오는 HttpServletRequest
에서 Long
타입인 memberId
를 추출하려면 HandlerMethodArgumentResolver
을 사용하는 것이 바람직하다.
인터셉터를 사용할 수 있고, 매개변수를 처리하는 과정이 필요하므로
HandlerMethodArgumentResolver
를 사용했다.
만약 매개변수를 처리하는 과정이 필요 없다면 인터셉터를 사용하는 게 좋을 것 같다. (URL 지정이 가능하므로)
HandlerMethodArgumentResolver
를 사용하는 법은 다음과 같다.
우선 HandlerMethodArgumentResolver 인터페이스를 구현한 클래스를 작성한다.
@Component
public class PrincipalArgumentResolver implements HandlerMethodArgumentResolver {
private static final String AUTHORIZATION_HEADER = "Authorization";
private final BasicAuthProvider basicAuthProvider;
public PrincipalResolver(BasicAuthProvider basicAuthProvider) {
this.basicAuthProvider = basicAuthProvider;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(Long.class) && parameter.hasParameterAnnotation(Principal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
return basicAuthProvider.resolveMemberId(request.getHeader(AUTHORIZATION_HEADER));
}
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Principal {
}
@Component
public class BasicAuthProvider {
private static final String BASIC_HEADER = "Basic ";
private static final String DELIMITER = ":";
private static final int EMAIL_INDEX = 0;
private static final int PASSWORD_INDEX = 1;
private static final int CREDENTIALS_LENGTH = 2;
private final MemberDao memberDao;
public BasicAuthProvider(MemberDao memberDao) {
this.memberDao = memberDao;
}
public User resolveUser(String token) {
String[] credentials = getCredentials(token);
validateCredentials(credentials);
String email = credentials[EMAIL_INDEX];
String password = credentials[PASSWORD_INDEX];
return getMemberId(email, password);
}
private String[] getCredentials(String token) {
if (!StringUtils.hasText(token)) {
throw new AuthenticationException("인증 토큰이 비어있습니다.");
}
if (!token.startsWith(BASIC_HEADER)) {
throw new AuthenticationException("베이직 형식의 토큰이 아닙니다.");
}
String decodeToken = getDecodeToken(trimToken(token));
return decodeToken.split(DELIMITER);
}
private static String trimToken(String token) {
return token.substring(BASIC_HEADER.length()).strip();
}
private String getDecodeToken(String token) {
try {
return new String(Base64Utils.decodeFromString(token));
} catch (IllegalArgumentException e) {
throw new AuthenticationException("올바른 형식의 토큰이 아닙니다.");
}
}
private void validateCredentials(String[] credentials) {
if (credentials.length != CREDENTIALS_LENGTH) {
throw new AuthenticationException("올바른 형식의 토큰이 아닙니다.");
}
}
private Long getMemberId(String email, String password) {
Optional<Long> memberId = memberDao.findByEmailAndPassword(email, password);
return memberId.orElseThrow(() -> new AuthenticationException("해당 이메일이 존재하지 않거나 비밀번호가 틀렸습니다."));
}
}
Basic 기반의 인증 방식에서 넘어온 BASE64로 인코딩된 문자열은 정확히는 토큰이라고 부를 수 없다.
예시 코드에선 편의를 위해 네이밍을 토큰이라고 하였다.
HandlerMethodArgumentResolver
를 구현하려면 supportsParameter
와 resolveArgument
메서드를 구현해야 한다.
컨트롤러 핸들러 메서드의 파라미터가 supportsParameter
의 조건과 맞는다면 resolveArgument
메서드를 실행하여 파라미터를 중간에서 처리할 수 있다.
그 뒤 WebMvcConfigurer
를 구현한 클래스를 정의하고 스프링 빈으로 등록한다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final PrincipalResolver principalResolver;
public WebMvcConfig(PrincipalResolver principalResolver) {
this.principalResolver = principalResolver;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(principalResolver);
}
}
addArgumentResolvers
를 재정의하고, 매개변수로 주어진 resolvers
리스트에 우리가 구현했던 PrincipalResolver
를 넣어준다.
WebMvcConfigurer
인터페이스를 구현한 클래스를 스프링 빈으로 등록하면, 스프링이 WebMvcConfigurationSupport
클래스를 사용하여 설정을 초기화한다.
@Configuration
말고@Component
로도 Spring MVC의 설정이 가능하다. 하지만 해당 빈의 목적이 설정과 같은@Configuration
과 적합하므로@Configuration
을 사용했다.
그리고 컨트롤러의 로직을 다음과 같이 리팩터링 할 수 있다.
@RestController
@RequestMapping("/api/cart")
public class CartApiController {
private final CartService cartService;
public CartApiController(CartService cartService) {
this.cartService = cartService;
}
@PostMapping("/{productId}")
public ResponseEntity<Response> addProductToCart(@PathVariable Long productId, @Principal Long memberId) {
cartService.addToCart(memberId, productId);
return ResponseEntity.ok()
.body(SimpleResponse.ok("장바구니에 상품이 담겼습니다."));
}
@GetMapping
public ResponseEntity<Response> findAllCartProducts(@Principal Long memberId) {
List<CartProductDto> allProducts = cartService.findAllCartProducts(memberId);
return ResponseEntity.ok()
.body(ResultResponse.ok(allProducts.size() + "개의 상품이 조회되었습니다.", allProducts));
}
@DeleteMapping("/{cartId}")
public ResponseEntity<Response> deleteProductToCart(@PathVariable Long cartId, @Principal Long memberId) {
cartService.deleteProduct(memberId, cartId);
return ResponseEntity.ok()
.body(SimpleResponse.ok("장바구니에 상품이 삭제되었습니다."));
}
}
더 이상 컨트롤러에는 인증과 관련된 코드가 하나도 없다.
인증에 관한 중복을 제거했을 뿐 아니라, 역할과 책임의 분리도 이루어졌다.
앞으로 인증이 필요한 핸들러 메서드가 생길 경우 매개변수에 @Principal
을 붙이면 인증 작업을 매우 편리하게 사용할 수 있다.
물론 정확한 역할과 책임의 분리는 했다고 말할 수 없다.
PrincipalArgumentResolver
클래스에서 계정 정보를 가져오는 과정 중 매개변수를 처리하는 작업뿐 아니라, 인증 작업을 수행하기 때문이다.
따라서 Interceptor로 인증 작업을 외부로 분리하는 것이 좋을 것 같다.
스프링 MVC를 사용할 때 WebMvcConfigurer
를 구현한 클래스에서 다양한 설정 정보를 커스터마이징 할 수 있다.
그 중 HandlerMethodArgumentResolver
를 구현한 클래스를 재구현 한 addArgumentResolvers
에 넣어, 컨트롤러에 들어오는 파라미터를 사용자가 정의한 대로 처리할 수 있다.
컨트롤러에서 인증 로직과 헤더에 있는 값을 꺼내 원하는 정보로 처리하는 과정을 외부로부터 분리하여 단일 책임 원칙을 지키며 확장과 유지보수성이 높은 프로그램을 설계할 수 있게 됐다.