Spring Security 적용해보기

지난번에 spring boot를 이용해서 graphQL서버를 구성해보았는데, 서비스를 운영할때 가장 중요한 보안을 설정하기 위해 springSecurity를 적용한 사례를 작성해보려고 한다.

우선, 인증 방식을 선택해야 하는데, 버스정보 어플같은 경우 사용자를 구분할 별도의 인증이 필요없기 때문에 간단하게 api-key를 통한 인증을 구현해보았다. graphQL로 kickstart 라이브러리를 이용하여 구현했기 때문에 다른 라이브러리를 이용했다면 Resolver와 같은 Handler클래스는 형식이 달라질 수 있다.

라이브러리 추가

dependencies {
  ... 그 외 라이브러리

  //spring security
  compile "org.springframework.boot:spring-boot-starter-security"
}

gradle은 위와 같이 한줄을 추가 해주면 되고 maven은 maven repository에서 버전을 선택해 의존성 추가해주면된다.



WebSecurityConfigurerAdapter를 상속받은 Config클래스 생성

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class GraphQLSecurityConfig extends WebSecurityConfigurerAdapter {
    public static final String USER_ID_PRE_AUTH_HEADER = "api_key";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // api-key auth 필터 추가
            .addFilterBefore(new ApiKeyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            // 모든 endpoint는 인증이 필요
            .anyRequest().authenticated()
            .and()
            // session manager filter 사용 x
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .csrf().disable()
            .httpBasic().disable()
            // Disable the /logout filter
            .logout().disable()
            // Disable anonymous users
            .anonymous().disable();
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/playground");
    }
}

spiring security를 의존성 추가만 해줘도 springsecurity filter들이 자동으로 등록되어 모든 도메인에대한 접속이 인증이 필요하게 되는데 우리는 서버 접속에 id/pw방식을 사용하지 않을 것이기 때문에 filter를 custom해주어야한다.

그래서 WebSecurityConfigurerAdapter를 상속하여 configure메서드를 오버라이딩 해주어 재설정 해주면된다. @EnableWebSecurity어노테이션을 달면 springSecurity filter들이 자동으로 포함되고, @EnableGlobalMethodSecurity(prePostEnabled = true)어노테이션을 통해 컨트롤러에 존재하는 메서드들에 @PreAuthorize()어노테이션을 통해 권한여부를 파악할 수 있게 해준다.

graphQL은 endpoint가 한개이므로 도메인별 권한을 config에서 부여하기 힘들기 대문에 이 어노테이션 방식을 통해 권한을 인증하는 방식으로 구성해야 한다.

configure메서드를 통해 기본인증방식인 form방식/session/httpBasic등을 비활성화 해주고, 웹으로 /playground접속을 할 수 있게 WebSecurity를 매개변수로 갖는 configure메서드에서 위와 같이 추가해주었다.



Filter 추가

import static wetayo.wetayoapi.config.security.GraphQLSecurityConfig.USER_ID_PRE_AUTH_HEADER;

public class ApiKeyAuthenticationFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        if(request instanceof HttpServletRequest && response instanceof HttpServletResponse){
            String apiKey = getApiKey((HttpServletRequest) request);
            if(apiKey != null){
                ApiKeyAuthenticationToken apiToken = new ApiKeyAuthenticationToken(apiKey);
                SecurityContextHolder.getContext().setAuthentication(apiToken);
            }else {
                HttpServletResponse httpResponse = (HttpServletResponse) response;
                httpResponse.setStatus(403);
            }
            chain.doFilter(request, response);
        }
    }
    private String getApiKey(HttpServletRequest httpRequest) {
        return httpRequest.getHeader(USER_ID_PRE_AUTH_HEADER);
    }
}

필터를 하나 생성하여 http요청 헤더에서 api-key라는 헤더가 있는지 검사하는 로직을 수행하게 해주었는데, filter를 custom으로 생성을 해주었으면 chain.doFilter(req,res)를 꼭 넣어줘어야 필터체인의 다음 필터를 이어서 수행하기 때문에 빼먹지말자.

헤더에 api-key가 존재하면 해당 api-key가 유요한 key인지 인증(검사)를 해야 하기 때문에 를 Set해주면 된다.

◾ 커스텀 인증 객체 생성

public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
    private String apiKey;

    public ApiKeyAuthenticationToken(String apiKey) {
        super(null);
        this.apiKey = apiKey;
    }

    public ApiKeyAuthenticationToken(Collection<? extends GrantedAuthority> authorities, String apiKey) {
        super(authorities);
        this.apiKey = apiKey;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
    return null;
    }

    @Override
    public Object getPrincipal() {
        return apiKey;
    }
}

SecurityContexholder에 추가해줄 Custom한 Authentication객체를 만들어야 하는데 AbstractAuthenticationToken을 상속받아 객체를 정의 해주면 되고 apiKey만 파라미터로하는 생성자는 인증전의 객체이며, Role을 담은 authorities를 포함하여 객체를 생성하면 인증 후의 객체로 setAuthrenticatd(true)을 수행해주면 된다.

위의 필터에서는 apikey만을 파라미터로 주고있기 때문에 apikey가 있는지 검사하는 필터일 뿐이지 인증을 수행하지는 않은 상태이다.



Provider 생성

@Component
public class ApiKeyProvider implements AuthenticationProvider {
    private final AuthProperties authProperties;

    public ApiKeyProvider(AuthProperties authProperties) {
        this.authProperties = authProperties;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if(authentication.isAuthenticated()) return authentication;
        if(authentication.getPrincipal().toString().equals(authProperties.getApiKey().get("user"))){
            ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(AuthorityUtils.createAuthorityList("user"),authentication.getPrincipal().toString());
            token.setAuthenticated(true);
            return token;
        }else if(authentication.getPrincipal().toString().equals(authProperties.getApiKey().get("bus"))){
            ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(AuthorityUtils.createAuthorityList("bus"),authentication.getPrincipal().toString());
            token.setAuthenticated(true);
            return token;
        }
        else throw new AccessDeniedException("invalid api-key");
    }

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

UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter, FilterSecurityInterceptor에서 Authentication Manager를 통해 Authentication객체의 인증을 수행하게 되는데 이때 Manger는 각종 Provider를 이용해서 인증객체를 인증한다.

많은 Provider중에서 supports메서드를 통해 인증할 객체타입에 맞는 Provider를 찾아 인증을 수행하기 때문에 일일히 Filter에서 특정 Provider를 사용하도록 Manager에게 알려줄 필요가 없다. 그래서 Provider를 정의할때는 supports도 override하여 해당 객체타입임을 알려주어야 한다.

Manager가 적합한 provider를 찾으면 수행하는 메서드인 authenticate를 override하여 여기에 인증 과정을 정의해주면 된다. 나는 application.properties에 미리 api-key를 생성해놓고 이를 이용해 비교로직을 수행하기 위해 AuthProperties라는 객체를 따로 빈으로 생성해서 .properties의 값을 가져오도록 했다.

값이 같다면 역할(Authority)을 포함한 인증객체를 생성해 return하여 인증객체가 인증되었음(setAuthenticated(true))을 수행토록 했다.



controller의 메서드들에 @PreAuthorize추가

@Component
@Slf4j
public class WetayoQuery implements GraphQLQueryResolver {
    private final StationService stationService;
    private final RouteStationService routeStationService;
    private final RideService rideService;
    private final RouteService routeService;

    private final ModelMapper modelMapper;


    WetayoQuery(StationService stationService, ModelMapper modelMapper, RouteStationService routeStationService
            , RideService rideService, RouteService routeService) {
        this.stationService = stationService;
        this.modelMapper = modelMapper;
        this.routeStationService = routeStationService;
        this.rideService = rideService;
        this.routeService = routeService;
    }

    @PreAuthorize("hasAuthority('user')")
    public List<RouteStationGraphQLDto> getStations(Double x, Double y, Double distance, DataFetchingEnvironment e) {
        List<Station> stations = stationService.getNearByStations(x, y, distance);
        List<RouteStationGraphQLDto> routeStationDtos = new ArrayList<>(); // = Arrays.asList(modelMapper.map(stations, RouteStationGraphQLDto[].class));

        stations.forEach(station -> {
            RouteStationGraphQLDto tmp = modelMapper.map(station,RouteStationGraphQLDto.class);
            tmp.setDistance(GeometryUtil.calculateDistance(x, y, station.getGps().getX(), station.getGps().getY()));
            routeStationDtos.add(tmp);
        });
        routeStationDtos.sort(Comparator.comparing(RouteStationGraphQLDto::getDistance));

        if(e.getSelectionSet().contains("routes")) {
            for (RouteStationGraphQLDto routeStationDto : routeStationDtos) {
                List<RouteStation> routeStations = routeStationService.findByStationId(routeStationDto.getStationId());
                List<RouteDto> routeGraphQLDtos = Arrays.asList(modelMapper.map(routeStations, RouteDto[].class));
                routeStationDto.setRoutes(routeGraphQLDtos);
            }
        }
        log.info("Query Stations (요청 gps : " + x + ", " + y + ") = " + routeStationDtos);
        return routeStationDtos;
    }

    @PreAuthorize("hasAuthority('bus')")
    public boolean getRide(Integer stationId, Integer routeId) {
        rideService.getRide(stationId, routeId);
        log.info("Query Ride(stationId : " + stationId + ", routeId : " + routeId );
        return true;
    }

    @PreAuthorize("hasAuthority('bus')")
    public List<RouteDto> getRoutes(String regionName) {
        List<Route> routes = routeService.getRoutes("%" + regionName + "%");;
        List<RouteDto> routeDtos = Arrays.asList(modelMapper.map(routes, RouteDto[].class));;

        log.info("Query Routes (요청 지역 이름 : " + regionName + ") = " + routeDtos);
        return routeDtos;
    }
}

GraphQL의 contoller역할을 수행하는 QueryResolver의 메서드들에 어노테이션들을 붙여, api-key에 따른 역할을 구분하고 권한인증을 처리하도록 수행해주면 간단한 api-key를 통한 인증이 마무리 된다.



결과

result

http://localhost:8080/playground에 접속해서 header를 셋팅하고 query를 날려보면 정상적으로 응답이 잘 오는 것을 볼 수 있다. 꼭 playground가 아니고 postman과 같은 프로그램을 이용해도 무방하다.