Last update: @7/1/2023
인증(Authentication)과 인가(Authorization)
•
인증(Authentication)은 로그인을 뜻함
•
인가(Authorization)는 허가를 뜻함
◦
어떤 서비스를 로그인만 해도 이용할 수 있도록 하는 것은 인증을 통해 인가를 하는 것이고,
◦
로그인을 했더라도 어드민만 손댈 수 있다면 인증 후 권한에 따라 인가를 하는 것이며,
◦
엑세스 토큰만 제출하면 서비스를 이용할 수 있다면 인증 없이 인가를 하는 것이라 볼 수 있음
인터셉터 인가(Authorization) 처리 공통화
•
각 컨트롤러마다 특정 자원(주로 DB 데이터)에 대해 유저가 권한을 가지고 있는지 일일이 확인하는 일은 심히 번거로움
•
이를 해결하기 위해 스프링 인터셉터에서 아래와 같은 방법으로 공통된 인가 처리를 구현함
◦
인가가 필요한 자원에 접근하기 위해서는 해당 자원의 id가 파라미터 키로 넘어가는 점에서 착안함
◦
학습(learning)이라면 learningId, 문장(sentence)은 sentenceId 등이 파라미터 키가 됨
◦
URI 쿼리 파라미터, POST body 쿼리 파라미터, PathVariable에 해당 자원의 id가 포함되어있을 경우 인가 검증을 진행하도록 인터셉터를 추가함
AuthorizationService 클래스 추가
•
먼저 AuthorizationService 클래스를 만들어 스프링 빈으로 등록함
@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class AuthorizationService {
private final LearningRepository learningRepository;
private final SentenceRepository sentenceRepository;
private final CollectionSentenceRepository collectionSentenceRepository;
private final CollectionLearningRepository collectionLearningRepository;
public static final String[] RESOURCE_PARAM_KEYS =
new String[]{"learningId", "sentenceId", "collectionSentenceId", "collectionLearningId"};
public void validate(String paramKey, String paramValue, Authentication authentication) {
validate(paramKey, new String[]{paramValue}, authentication);
}
public void validate(String paramKey, String[] paramValues, Authentication authentication) {
List<Long> ids = Arrays.stream(paramValues)
.filter(StringUtils::hasText)
.map(Long::valueOf)
.toList();
if (ids.size() == 0) {
return;
}
try {
paramKey = paramKey.substring(0, 1).toUpperCase() + paramKey.substring(1);
Method method = this.getClass().getMethod("validate" + paramKey, List.class, Long.class);
boolean isValid = (boolean) method.invoke(this, ids, authentication.getMemberId());
if (!isValid) {
throw new UnauthorizedException("허가되지 않은 접근입니다.");
}
} catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {
throw new IllegalStateException(e);
}
}
public boolean validateLearningId(List<Long> ids, Long memberId) {
return learningRepository.findUnauthorizedOne(ids, memberId).isEmpty();
}
public boolean validateSentenceId(List<Long> ids, Long memberId) {
return sentenceRepository.findUnauthorizedOne(ids, memberId).isEmpty();
}
public boolean validateCollectionSentenceId(List<Long> ids, Long memberId) {
return collectionSentenceRepository.findUnauthorizedOne(ids, memberId).isEmpty();
}
public boolean validateCollectionLearningId(List<Long> ids, Long memberId) {
return collectionLearningRepository.findUnauthorizedOne(ids, memberId).isEmpty();
}
}
Java
복사
◦
RESOURCE_PARAM_KEYS는 HTTP 요청에 포함될 경우 인가 검증을 진행할 리소스 파라미터 키(id) 목록임
◦
먼저 파라미터 키 하나에 여러 값이 들어올 수 있고, 빈 문자열도 들어올 수 있기 때문에 필터링을 거친 후 Long 타입으로 변환함
List<Long> ids = Arrays.stream(paramValues)
.filter(StringUtils::hasText)
.map(Long::valueOf)
.toList();
Java
복사
◦
파라미터값의 첫 문자를 대문자로 바꾼 후 리플렉션으로 검증 메서드를 호출함
try {
paramKey = paramKey.substring(0, 1).toUpperCase() + paramKey.substring(1);
Method method = this.getClass().getMethod("validate" + paramKey, List.class, Long.class);
boolean isValid = (boolean) method.invoke(this, ids, authentication.getMemberId());
if (!isValid) {
throw new UnauthorizedException("허가되지 않은 접근입니다.");
}
} catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {
throw new IllegalStateException(e);
}
Java
복사
▪
리소스의 id값이 learningId라고 하면 validateLearningId 메서드를 호출하게 됨
•
검증 메서드에서는 요청된 리소스 id값들 중 유저에게 권한이 없는 것이 하나라도 있는지 확인함
public boolean validateLearningId(List<Long> ids, Long memberId) {
return learningRepository.findUnauthorizedOne(ids, memberId).isEmpty();
}
Java
복사
◦
아래는 QueryDSL JPA 코드. in절과 not equal을 통해 권한 없는 한 개를 찾음
@Override
public Optional<Object> findUnauthorizedOne(List<Long> ids, Long memberId) {
return Optional.ofNullable(
queryFactory.
selectFrom(sentence)
.where(
sentence.id.in(ids),
memberIdNe(memberId)
)
.limit(1)
.fetchOne()
);
}
Java
복사
AuthorizationInterceptor 클래스 추가
•
이제 요청을 통해 넘어오는 파라미터 이름을 조사해서 RESOURCE_PARAM_KEYS에 있을 경우 검증을 진행함
@RequiredArgsConstructor
public class AuthorizationInterceptor implements HandlerInterceptor {
private final AuthorizationService authorizationService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Authentication authentication = (Authentication) request.getAttribute("authentication");
Map<String, String[]> parameterMap = request.getParameterMap();
for (String paramKey : AuthorizationService.RESOURCE_PARAM_KEYS) {
String[] resourceIds = parameterMap.get(paramKey);
if (resourceIds != null && resourceIds.length > 0) {
authorizationService.validate(paramKey, resourceIds, authentication);
}
}
Map<String, String> pathVariables = (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (pathVariables != null) {
for (String paramKey : AuthorizationService.RESOURCE_PARAM_KEYS) {
String resourceId = pathVariables.get(paramKey);
if (StringUtils.hasText(resourceId)) {
authorizationService.validate(paramKey, resourceId, authentication);
}
}
}
return true;
}
}
Java
복사
◦
authentication attribute는 인증 인터셉터로부터 넘겨받은 인증 정보
◦
request.getParameterMap()을 통해 URI와 POST body의 parameter를 받아와 조사함
◦
request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)를 통해 path variable을 받아와 조사함
인터셉터 등록
•
WebConfig에서 인터셉터를 등록함
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthorizationService authorizationService;
private final List<String> RESOURCES_URL =
new ArrayList<>(Arrays.asList(
"/images/**",
"/js/**",
"/css/**",
"/*.ico",
"/error/**",
"/health/**"
));
@Override
public void addInterceptors(InterceptorRegistry registry) {
...
registry.addInterceptor(new AuthorizationInterceptor(authorizationService))
.order(3)
.addPathPatterns("/**")
.excludePathPatterns(RESOURCES_URL);
}
Java
복사