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());
}
}