
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 등 여러가지 타입으로 파라미터를 전달 받을 수 있다.
어떻게 가능할까?
바로 HandlerMethodArgumentResolver(이하 ArgumentResolver) 때문이다.
ArgumentResolver는 컨트롤러가 필요로 하는 파라미터의 값을 생성해 준다. 스프링에서는 30개가 넘는 ArgumentResolver를 기본으로 제공한다고 한다.
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는 어디에서 호출될까?
public class RequestMappingHandlerAdapter
extends AbstractHandlerMethodAdapter
implements BeanFactoryAware, InitializingBean {
// ...
@Nullable
private HandlerMethodArgumentResolverComposite argumentResolvers;
// ...
}
RequestMappingHandlerAdapter는 argumentResolvers라는 필드를 가지고 있는데 여기에 ArgumentResolver 리스트가 있다.
RequestMappingHandlerAdapter는 argumentResolvers를 순회하면서 supportsParameter() 메서드의 결과가 true일 경우 순회를 멈추고 resolveArgument()를 호출하여 핸들러가 필요로 하는 인스턴스를 반환 받는다.
RequestMappingHandlerAdapter는 ArgumentResolver에 의해 생성된 인스턴스들을 핸들러를 호출하면서 같이 전달해준다.
이제 ArgumentResolver를 구현하여 컨트롤러에서 요청 헤더의 Authorization 토큰을 Member 타입으로 바인딩하여 아래와 같이 받아보자.
@GetMapping()
public void getAll(@Auth(Role.ADMIN) Member member) {...}
@Getter
@AllArgsConstructor
public enum Role {
USER(0), ADMIN(1);
private final int code;
}
@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;
}
}
uid는 토큰에서 얻을 수 있는 유니크한 값이라 가정하였다.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;
}
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를 호출... 등등)
package com.example.demo.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class TokenInfo {
private String token;
private String uid;
// ...
}
package com.example.demo.service;
import com.example.demo.dto.TokenInfo;
public interface AuthService {
TokenInfo verifyToken(String token);
}
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;
}
}
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는 다른 멤버 조회 불가코드가 길어서 잘라서 적었다.
@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));
}
}
@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());
}
}
