[Spring Security] Back-End - Spring Security설정(With JWT) - 3

2024. 11. 28. 17:24JAVA/Spring Security

앞서 기본 설정이 완료 되었다면 이제 SecurityConfig 파일을 하나씩 채워나가야 한다.

URL 및 권한 

나는 Database에서 URL에 따른 권한을 설정하기 때문에 FilterInvocationSecurityMetadataSource Interface를 구현하여 URL에 따른 권한을 설정하도록 한다.

SecurityConfig.java

public class SecurityConfig {

	private final SecurityService securityService;

	@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

		http
			.csrf(AbstractHttpConfigurer::disable)

			//Login, Logout 등 기본 방식 미사용 처리
			.formLogin(AbstractHttpConfigurer::disable)
			.logout(AbstractHttpConfigurer::disable)
			.httpBasic(AbstractHttpConfigurer::disable)

			//JWT를 사용할 예정으로 Session 사용하지 않음
			.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

			//요청 URL 검증 추리
			.authorizeHttpRequests(requests ->
							requests
								.anyRequest()
									.access(customAuthorizationManager())
						)
		;

		return http.build();
	}

	@Bean
	AuthorizationManager<RequestAuthorizationContext> customAuthorizationManager() {
		return new CustomAuthorizationManager(customSecurityMetadataSource());
	}

	CustomSecurityMetadataSource customSecurityMetadataSource() {
		LinkedHashMap<RequestMatcher, List<ConfigAttribute>> destMap = securityService.selectUrlRoleMapping();
		CustomSecurityMetadataSource rfism = new CustomSecurityMetadataSource(destMap);
		return rfism;
	}
}

위의 소스에서 보면 2가지가 추가 되었다.

1. CustomSecurityMetadataSource.java

package kr.co.infob.config.security.intercept;

import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;

@Component
public class CustomSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

	private final Map<RequestMatcher, List<ConfigAttribute>> requestMap;

	public CustomSecurityMetadataSource(LinkedHashMap<RequestMatcher, List<ConfigAttribute>> destMap) {
    	this.requestMap = destMap;
    }

	@Override
	public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {

		RequestAuthorizationContext requestAuthorizationContext = (RequestAuthorizationContext)object;

		HttpServletRequest request = requestAuthorizationContext.getRequest();

		Collection<ConfigAttribute> result = null;
		for(Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap.entrySet()){
			if(entry.getKey().matches(request)){
				result = entry.getValue();
				break;
			}
		}
		return result;
	}

	@Override
	public Collection<ConfigAttribute> getAllConfigAttributes() {
		Set<ConfigAttribute> allAttributes = new HashSet<>();
		for(Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap.entrySet()){
			allAttributes.addAll(entry.getValue());
		}
		return allAttributes;
	}

	@Override
	public boolean supports(Class<?> clazz) {
		return FilterInvocation.class.isAssignableFrom(clazz);
	}
}

FilterInvocationSecurityMetadataSource Interface를 구현한것이다.

아래는 SpringSecurity에서 추가된 항목이다.

CustomSecurityMetadataSource customSecurityMetadataSource() {
    LinkedHashMap<RequestMatcher, List<ConfigAttribute>> destMap = securityService.selectUrlRoleMapping();
    CustomSecurityMetadataSource rfism = new CustomSecurityMetadataSource(destMap);
    return rfism;
}

SecurityService 에서 URL에 따른 ROLE Mapping 정보를 호출하여 CustomSecurityMetadataSource에 전달한다.
그러면 CustomSecurityMetadataSource 에서 해당 정보를 통하여 URL 매핑과 HTTP 메서드에 따라 요청에 필요한 권한을 반환합니다.
(예: /api/vi/** → ROLE_SYSADM, /user/** → ROLE_USER.)

 

2. CustomAuthorizationManager.java

package kr.co.infob.config.security.authorization;

import java.util.Collection;
import java.util.function.Supplier;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.util.CollectionUtils;

import kr.co.infob.config.security.intercept.CustomSecurityMetadataSource;

public class CustomAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

	private final CustomSecurityMetadataSource customSecurityMetadataSource;


	public CustomAuthorizationManager(CustomSecurityMetadataSource customSecurityMetadataSource) {
		this.customSecurityMetadataSource = customSecurityMetadataSource;
	}

	@Override
	public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
		try {

			Collection<ConfigAttribute> attributes = customSecurityMetadataSource.getAttributes(object);
			if( CollectionUtils.isEmpty(attributes) ) {
				return new AuthorizationDecision(false);
			}

			Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();

			boolean hasAuthority =  attributes.stream()
							        .anyMatch(attribute -> authorities.stream()
							        	.anyMatch(authority ->
										attribute.getAttribute().equals(authority.getAuthority().replaceFirst("^ROLE_", ""))
							        	)
							        );

			return new AuthorizationDecision(hasAuthority);

		} catch (AccessDeniedException e) {

			return new AuthorizationDecision(false);
		}
	}

}

 

실질적으로 해당 URL에 권한이 있는지 여부를 판단하는 컴포넌트이다.
Security 6 에서 부터 도입된걸로 안다. 이전 버전에서는 AccessDecisionManager를 사용하였으나 AuthorizationManager를 사용함으로 더욱 유연하고 명확하게 그리고 간결하게 소스를 작성할 수 있다고 한다.

이것 때문에 한참고생했다..

하여튼, customSecurityMetadataSource에서 추출된 권한이 현재 사용자에게 있는지 여부를 판단한다.
만약 권한이 있다면  AuthorizationDecision에 true(허용)을 없는 경우에는 false(거부)를 받아 결과를 반환한다.

만약 허용하지 않는다면(false) AccessDenieException 이 발생하게 된다.

SecurityConfig.java

@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

		http
			...

			//요청 URL 검증 추리
			.authorizeHttpRequests(requests ->
						requests
							.anyRequest()
								.access(customAuthorizationManager())
					)
		;
        
		...
	}

	@Bean
	AuthorizationManager<RequestAuthorizationContext> customAuthorizationManager() {
		return new CustomAuthorizationManager(customSecurityMetadataSource());
	}

	CustomSecurityMetadataSource customSecurityMetadataSource() {
		LinkedHashMap<RequestMatcher, List<ConfigAttribute>> destMap = securityService.selectUrlRoleMapping();
		CustomSecurityMetadataSource rfism = new CustomSecurityMetadataSource(destMap);
		return rfism;
	}

위의 소스에서 http.authorizeHttpRequests(requests -> requests.anyRequest().access(customAuthorizationManager())) 이 부분이 변경되었다.

"URL 접근 권한 관리를 customAuthorizationManager를 통해서 하겠다는 것이다" 라고 하는 것이다.

여기까지 설정이 되었다면 테스트를 진행해 보겠다.
이전에 UrlRoleMapping 가져오는 쿼리를 날려 보면

이와 같이 나올것이다.

여기서 보면 
/api/v1/auth/signup과 /api/v1/auth/signin 는 ANONYMOUS도 접근이 가능하지만
/api/v1/auth/mypage는 ANONYMOUS는 접근을 할 수 없도록 되어 있다. 당연히 로그인한 사람만 볼 수 있어야 하니까.

Postman으로 /api/v1/auth/signup을 호출해 보겠다.

역시 잘 실행이 된다.

이제 /api/v1/mypage를 호출해 보겠다.

그러기 위해서 Controller에 생성을 해 준다.

package kr.co.infob.api.auth.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1")
public class AuthController {

	@GetMapping("/auth/signup")
	public ResponseEntity<?> signup() {
		return ResponseEntity.ok("Sign UP!!!!!!");
	}

	@GetMapping("/mypage")
	public ResponseEntity<?> mypage() {
		return ResponseEntity.ok("MyPage!!!!!!");
	}

}

그리고 Postman으로 요청한다.

 

Postman에서는 아무 결과가 없고 이클립스 콘솔에서 다음과 같은 로그가 찍힐 것이다.

 

 

권한이 없어서 /error 화면으로 이동시키는 것이다.
아까 위에서 설정한 customSecurityMetadataSource에서 false을 반환했기 때문에 AccessDenieException이 발생해서 error url을 찾게 된다.

그런데 우리는 화면이 없기 때문에 저렇게 보내면 안된다. JSON 형태로 던져줘야 한다.

그러기 위해서 SecurityConfig에서 별도의 2가지 exception 처리를 해 줘야 한다.

우선은 사용자에게 여러가지 메시지를 전송해줄 Response Class를 생성하겠다.

Response.java

package kr.co.infob.common.vo;

import org.springframework.http.HttpStatus;

import lombok.Builder;
import lombok.Getter;

@Getter
public class Response<T> {

	private int status;

	private String message;

	private T data;

    @Builder
    public Response(int status, String message, T data) {
        this.status = status;
        this.message = message;
        this.data = data;
    }

    public static <T> Response<T> success(T data) {
        return Response.<T>builder()
                .status(HttpStatus.OK.value())
                .message("Success")
                .data(data)
                .build();
    }

    public static <T> Response<T> error(String message, HttpStatus status) {
        return Response.<T>builder()
                .status(status.value())
                .message(message)
                .data(null)
                .build();
    }

    public int getStatus() {
    	return this.status;
    }
}

단순히 메시지 전송용 Class이다.

그리고 자주 사용하게 될 Response를 실직적으로 JSON형태로 출력해서 주는 Util도 생성한다.

RequestUtil.java

package kr.co.infob.common.utils;

import java.io.IOException;

import org.springframework.http.MediaType;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.http.HttpServletResponse;
import kr.co.infob.common.vo.Response;

public class RequestUtil {

	/**
	 * Response 값을 사용자에게 JSON 형태로 출력해 준다.
	 * @param <T>
	 * @param httpServletResponse
	 * @param response
	 * @throws IOException
	 */
	public static <T> void printJsonResponse(HttpServletResponse httpServletResponse, Response<T> response) throws IOException {
		ObjectMapper mapper = new ObjectMapper();
		httpServletResponse.setStatus(response.getStatus());
		httpServletResponse.setCharacterEncoding("UTF-8");
		httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
		httpServletResponse.getWriter().write(mapper.writeValueAsString(response));
	}
}

해당 Util은 계속해서 메소드들이 추가될 예정이다.

그리고 최종적으로 AccessDeniedExcetion을 처리할 Handler을 생성한다.

CustomAccessDeniedHandler.java

package kr.co.infob.config.security.handler;

import java.io.IOException;

import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.infob.common.utils.RequestUtil;
import kr.co.infob.common.vo.Response;
import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response,
			AccessDeniedException accessDeniedException) throws IOException, ServletException {

		log.info("[CustomAccessDeniedHandler] :: {}", accessDeniedException.getMessage());
		log.info("[CustomAccessDeniedHandler] :: {}", request.getRequestURL());
		log.info("[CustomAccessDeniedHandler] :: 리소스에 접근권한이 없습니다.");

		Response<?> result = Response.error("You do not have permission to access that resource.", HttpStatus.FORBIDDEN);
		RequestUtil.printJsonResponse(response, result);

	}
}

 

CustomAuthenticationEntryPoint.java

package kr.co.infob.config.security.handler;

import java.io.IOException;

import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.infob.common.utils.RequestUtil;
import kr.co.infob.common.vo.Response;
import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException, ServletException {

		if( !"application/json".equals(request.getContentType()) ) {
			return;
		}

		log.debug("[CustomAuthenticationEntryPoint] :: {}", authException.getMessage());
		log.debug("[CustomAuthenticationEntryPoint] :: {}", request.getRequestURL());
		log.debug("[CustomAuthenticationEntryPoint] :: 사용자(토근) 정보가 만료되었거나 존재하지 않음");

		Response<?> result = Response.error("User(Token) information has expired or does not exist.", HttpStatus.FORBIDDEN);
		RequestUtil.printJsonResponse(response, result);
	}
}

 

이제 SecurityConfig에 추가한다.

	private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
	private final CustomAccessDeniedHandler customAccessDeniedHandler;
    
	@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

		http
			.csrf(AbstractHttpConfigurer::disable)

			...

			// Exception 처리
			.exceptionHandling(exception -> exception
						.authenticationEntryPoint(customAuthenticationEntryPoint)
						.accessDeniedHandler(customAccessDeniedHandler)
				)
		;

		return http.build();
	}

이제 다시 테스트 한다.

정상적으로 출력이 된다.

여기서 한가지 더

customSecurityMetadataSource를 통해 false 를 반환할 때 AccessDeniedExcetion발생하낟고 해서 무조건 AccessDeniedHandler로 이동되는 것은 아니다.

사용자가 익명(ANONYMOUS)일 때는 AnonymousAuthenticationToken에서 처리가 되어서 인증 실패로 간주하고AuthenticationEntryPoint로 이동된다고 한다.

로그인한 사용자의 경우 권한이 없는 경우에만 AccessDeniedHandler 로 이동한다고 한다.

 

여기까지 SecurityConfig 설정을 진행했고 이제  JWT를 적용하려고 한다.

SecurityConfig.java

package kr.co.infob.config.security;

import java.util.LinkedHashMap;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.util.matcher.RequestMatcher;

import kr.co.infob.config.security.authorization.CustomAuthorizationManager;
import kr.co.infob.config.security.handler.CustomAccessDeniedHandler;
import kr.co.infob.config.security.handler.CustomAuthenticationEntryPoint;
import kr.co.infob.config.security.intercept.CustomSecurityMetadataSource;
import kr.co.infob.config.security.service.SecurityService;
import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	private final SecurityService securityService;

	private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
	private final CustomAccessDeniedHandler customAccessDeniedHandler;

	@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

		http
			.csrf(AbstractHttpConfigurer::disable)

			//Login, Logout 등 기본 방식 미사용 처리
			.formLogin(AbstractHttpConfigurer::disable)
			.logout(AbstractHttpConfigurer::disable)
			.httpBasic(AbstractHttpConfigurer::disable)

			//JWT를 사용할 예정으로 Session 사용하지 않음
			.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

			//요청 URL 검증 추리
			.authorizeHttpRequests(requests ->
							requests
								.anyRequest()
									.access(customAuthorizationManager())
						)

			// Exception 처리
			.exceptionHandling(exception -> exception
								.authenticationEntryPoint(customAuthenticationEntryPoint)
								.accessDeniedHandler(customAccessDeniedHandler)
				)
		;

		return http.build();
	}

	@Bean
	AuthorizationManager<RequestAuthorizationContext> customAuthorizationManager() {
		return new CustomAuthorizationManager(customSecurityMetadataSource());
	}

	CustomSecurityMetadataSource customSecurityMetadataSource() {
		LinkedHashMap<RequestMatcher, List<ConfigAttribute>> destMap = securityService.selectUrlRoleMapping();
		CustomSecurityMetadataSource rfism = new CustomSecurityMetadataSource(destMap);
		return rfism;
	}
}