[사이드프로젝트] 그저 그런 REST API로 괜찮은가? - 진정한 REST API 구현해보기 - Method Security 적용 및 Event Update 예외 처리

gimseonjin616·2022년 3월 28일
0

EventControllerTest 수정


Event Controller Test를 수정하여 Spring Security가 제대로 적용됐는 지 확인해야한다.

고려해야할 사항은 다음과 같다

  • create 로직은 token이 있어야 성공한다.
  • event를 읽어올 때, token이 있으면 create & update 링크가 포함되어야 한다.
  • token 없을 시, create & update 링크가 제외된다.
  • update 로직은 token이 있어야 성공한다.
  • update 로직은 로그인한 유저가 작성자가 아니면 unauthorized 예외를 발생한다.
    public String getToken() throws Exception {
        String clientId = "myapp";
        String clientSecret = "pass";
        Account account = createAccount();
        accountService.create(account);

        var response =  mockMvc.perform(post("/oauth/token")
                        .with(httpBasic(clientId, clientSecret))
                        .param("username", account.getUsername())
                        .param("password", "password")
                        .param("grant_type", "password")
                );
        var responseString = response.andReturn().getResponse().getContentAsString();
        Jackson2JsonParser paser = new Jackson2JsonParser();
        return "Bearer " + paser.parseMap(responseString).get("access_token").toString();
    }

    public String getToken(Account account) throws Exception {
        String clientId = "myapp";
        String clientSecret = "pass";

        var response =  mockMvc.perform(post("/oauth/token")
                .with(httpBasic(clientId, clientSecret))
                .param("username", account.getUsername())
                .param("password", "password")
                .param("grant_type", "password")
        );
        var responseString = response.andReturn().getResponse().getContentAsString();
        Jackson2JsonParser paser = new Jackson2JsonParser();
        return "Bearer " + paser.parseMap(responseString).get("access_token").toString();
    }
 
     @Test
    public void create_event_success() throws Exception {
        //Given
        EventDto eventDto = createEventDto();

        //When
        //Then
        mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(HttpHeaders.AUTHORIZATION, getToken())
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("event").exists())
                .andExpect(header().exists(HttpHeaders.LOCATION))
                .andExpect(header().string(HttpHeaders.CONTENT_TYPE,MediaTypes.HAL_JSON_VALUE))
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.query-events").exists())
                .andExpect(jsonPath("_links.profile").exists())
                .andExpect(jsonPath("_links.update-event").exists());
    }

 
     @Test
    public void create_event_unauthorized_without_token() throws Exception {
        //Given
        EventDto eventDto = createEventDto();

        //When
        //Then
        mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isUnauthorized());
    }
 
     @Test
    public void get_event_success() throws Exception {
        //Given
        Event event = createEvent(0);
        this.eventRepository.save(event);

        //When
        //Then
        mockMvc.perform(get("/api/events/{id}", event.getId()))
                .andDo(print())
                .andExpect(jsonPath("event").exists())
                .andExpect(jsonPath("_links").exists());
    }

    @Test
    public void get_event_success_with_token() throws Exception {
        //Given
        Event event = createEvent(0);
        this.eventRepository.save(event);

        //When
        //Then
        mockMvc.perform(get("/api/events/{id}", event.getId())
                        .header(HttpHeaders.AUTHORIZATION, getToken())
                )
                .andDo(print())
                .andExpect(jsonPath("event").exists())
                .andExpect(jsonPath("_links").exists())
                .andExpect(jsonPath("_links.create-events").exists());
    }

    @Test
    public void update_event_success() throws Exception {
        //Given
        Account account = createAccount();
        Event event = createEvent(11123, account);
        accountService.create(account);
        eventRepository.save(event);
        String newDescription = "new description";

        EventDto eventDto = EventDto.builder()
                .name(event.getName())
                .description(newDescription)
                .build();

        //When
        //Then
        mockMvc.perform(put("/api/events/{id}", event.getId())
                        .header(HttpHeaders.AUTHORIZATION, getToken(account))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(jsonPath("event.name").value(event.getName()))
                .andExpect(jsonPath("event.description").value(newDescription))
                .andExpect(jsonPath("_links").exists())
                .andExpect(jsonPath("_links.profile").exists());
    }

    @Test
    public void update_event_unauthorized_without_token() throws Exception {
        //Given
        Account account = createAccount();
        Event event = createEvent(11123, account);
        accountService.create(account);
        eventRepository.save(event);
        String newDescription = "new description";

        EventDto eventDto = EventDto.builder()
                .name(event.getName())
                .description(newDescription)
                .build();

        //When
        //Then
        mockMvc.perform(put("/api/events/{id}", event.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void update_event_unauthorized_wrong_account() throws Exception {
        //Given
        Account account = createAccount();
        Account wrongAccount = Account.builder()
                .name("wrong")
                .password("password")
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        Event event = createEvent(11123, account);
        accountService.create(account);
        accountService.create(wrongAccount);
        eventRepository.save(event);
        String newDescription = "new description";

        EventDto eventDto = EventDto.builder()
                .name(event.getName())
                .description(newDescription)
                .build();

        //When
        //Then
        mockMvc.perform(put("/api/events/{id}", event.getId())
                        .header(HttpHeaders.AUTHORIZATION, getToken(wrongAccount))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isUnauthorized());
    }

Method Security 구현


기본적으로 Event에서 Update와 Create는 권한이 없으면 진행할 수 없다.

따라서 @PreAuthorize 어노테이션을 활용해서 USER나 ADMIN 권한이 있는 경우에만 create와 update를 사용할 수 있도록 설정해준다.

    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public Event create(EventDto eventDto){
    ...
    }

    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public Event update(Integer id, EventDto eventDto){
    ...
    }

강의를 내가 필요한 부분만 들었기 때문에(중간 중간 생략...) Create Link 부분을 누락했다. 따라서 지금 급하게 구현한다.

    private void addCreateLink(RepresentationModel eventResource){
        WebMvcLinkBuilder queryLink =  linkTo(methodOn(EventController.class).create(new EventDto()));
        eventResource.add(queryLink.withRel("create-events"));
    }

그리고 이 CreateLink와 UpdateLink는 로그인이 되어 있는 경우에만 제공하도록 해야 한다.

이를 위해선 현재 로그인한 유저 정보를 가져와야 한다. Spring security에서는 Provider에서 발급하는 AuthenticationProvider에 저장한다. 그 중에서 유저 정보는 principal 객체로 관리한다. 따라서 이 principal 객체를 가져와야 한다.

AuthenticationProvider를 가져오기 위해선 SecurityContextHolder.getContext().getAuthentication() 메소드를 사용해야하며 principal 객체는 getPrincipal을 통해 가져올 수 있다.

이때 중요한 점은 이번 프로젝트에서는 annoymouse 사용자를 허용했다는 것이다. annoymouse 사용자는 미인증유저로 AuthenticationProvider내 principal 객체에 null 값을 가지고 있다.

따라서 getPrincipal()을 했을 때 가져오는 객체가 Account.class이면 create & update link를 제공해야 한다.
(추가 설명 : principal 객체는 UserDetailService의 loadUserByUsername 메소드에서 반환되는 객체랑 같다.)

addLink() 메소드를 다음과 같이 수정합니다.

    private void addLinks(EventResource eventResource, String profileLink){
        var principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        if(principal.getClass().equals(Account.class)){
            addCreateLink(eventResource);
            addUpdateLink(eventResource);
        }

        addQueryLink(eventResource);
        addSelfLink(eventResource);
        addProfileLink(eventResource, profileLink);
    }

Update시 Manager(작성자)와 로그인한 유저가 다를 시 예외 발생


Event 수정은 작성자만 할 수 있어야 한다.

이 부분은 Spring Security로 관리하기 어렵다.

따라서 별도의 Method로 관리하고자 한다.

우선 principal을 가져오기 와서 Event 객체의 Manager와 같은 지 비교하는 함수를 구현합니다.

    private Account getPrincipal(){
        return (Account)SecurityContextHolder.getContext().
                getAuthentication().getPrincipal();
    }

    private void validEventManager(Event event){
        Account currentAccount = getPrincipal();
        if(!event.getManager().equals(currentAccount))
            throw new CustomException(HttpStatus.UNAUTHORIZED, "이벤트 작성자가 아닙니다.");
    }

그 후 Event Update 로직으로 가서 validEventManager 함수를 추가해줍니다.

    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public Event update(Integer id, EventDto eventDto){
        Event event = this.read(id);
        validEventManager(event);
        event.update(eventDto);
        return this.eventRepository.save(event);
    }

테스트 결과


profile
백엔드 개발자

0개의 댓글

관련 채용 정보