개발강의정리/Spring

[스프링 기반 REST API 개발] 5-10. 스프링 시큐리티 현재 사용자 조회

nineDeveloper 2020. 4. 10.
728x90

스프링 기반 REST API 개발

5. REST API 보안 적용

포스팅 참조 정보

GitHub

공부한 내용은 GitHub에 공부용 Organizations에 정리 하고 있습니다

해당 포스팅에 대한 내용의 GitHub 주소

실습 내용이나 자세한 소스코드는 GitHub에 있습니다
포스팅 내용은 간략하게 추린 핵심 내용만 포스팅되어 있습니다

https://github.com/freespringlecture/spring-rest-api-study/tree/chap05-10_current_user_select

해당 포스팅 참고 인프런 강의

https://www.inflearn.com/course/spring_rest-api/dashboard

실습 환경

  • Java Version: Java 11
  • SpringBoot Version: 2.1.2.RELEASE

10. 스프링 시큐리티 현재 사용자 조회

궁극적인 목적은 현재 사용자를 Account로 받아오고 아니면 null

SecurityContext

자바 ThreadLocal 기반 구현으로 인증 정보를 담고 있다

인증 정보 꺼내는 방법:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

AccountService의 UserDetails의 구현체가 리턴해주는 스프링 시큐리티 유저를 받아 올 수 있음

해당 username으로 해당하는 유저를 DB에서 조회해올 수 있음

User principal = (User) authentication.getPrincipal();

@AuthenticationPrincipal spring.security.User user

스프링 시큐리티가 제공해주는 기능 중에 하나로 스프링 MVC 핸들러 파라메터에 @AuthenticationPrincipal를 사용하면 getPrincipal() 로 리턴 받을 수 있는 객체를 바로 주입받을 수가 있음

있는 지 없는지만 확인하는 용도로 사용

  • 인증 안한 경우에 null
  • 인증 한 경우에는 usernameauthorities 참조 가능

queryEvents를 호출 할때 user가 있는 경우 create-event 링크 추가

if(user != null) {
    // 유저가 있으면 EventController에 create-event 링크를 추가
    pagedResources.add(linkTo(EventController.class).withRel("create-event"));
}

이벤트를 생성할때 account 유저가 필요

이벤트를 생성할때 현재 사용자 정보를 이벤트에 주입을 해줘야 함 Event에 있는 manager를 셋팅해주려면
현재 userspringsecurity user가 아니라 account 여야함

spring.security.User를 상속받는 클래스를 구현하면

AccountServiceUserDetails의 구현체가 리턴해주는 스프링 시큐리티 유저를 Account객체를 알고있는 객체로 바꿔야함

  • 도메인 User를 받을 수 있다
  • @AuthenticationPrincipa me.freelife.user.UserAdapter
  • Adatepr.getUser().getId()

AccountAdapter 구현

스프링 시큐리티 유저를 Account객체를 알고있는 객체로 바꿔주는 클래스

package me.freelife.rest.accounts;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;

public class AccountAdpter extends User {

    private Account account;

    public AccountAdpter(Account account) {
        super(account.getEmail(), account.getPassword(), authorities(account.getRoles()));
        this.account = account;
    }

    private static Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles) {
        return roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))
                .collect(Collectors.toSet());
    }

    public Account getAccount() {
        return account;
    }
}

AccountAdapter로 리턴 하도록 수정

AccountServiceUserDetailsAccountAdapter로 리턴하도록 수정

이렇게 수정하면 이제 Controller에서 AccountAdapter를 받을 수 있음

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Account account = accountRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException(username)); // account 객체가 없으 UsernameNotFoundException 에러를 던짐
    return new AccountAdapter(account);
}

AccountAdapter 에서 유저정보 꺼내오기

DB에 접근하지 않고도 현재 사용자의 정보에 접근할 수 있으므로 이벤트를 생성할 때 현재 매니저를 셋팅할 수 있게 됨

@AuthenticationPrincipal 로 AccountAdapter 에서 현재 유저 꺼내오기

@AuthenticationPrincipal AccountAdapter currentUser
@GetMapping
public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler, @AuthenticationPrincipal AccountAdapter currentUser) {
    //현재 사용자 인증정보 가져오기
    Authentication  authentication = SecurityContextHolder.getContext().getAuthentication();

    Page<Event> page = this.eventRepository.findAll(pageable);
    var pagedResources = assembler.toResource(page, e -> new EventResource(e));
    //profile로 가는 Link를 추가
    pagedResources.add(new Link("/docs/index.html#resources-events-list").withRel("profile"));
    if(currentUser != null) {
        // 유저가 있으면 EventController에 create-event 링크를 추가
        pagedResources.add(linkTo(EventController.class).withRel("create-event"));
    }
    return ResponseEntity.ok(pagedResources);
}

SpEL을 사용해서 Account 정보만 꺼내옴

AccountAdapter에서 account라는 필드 값을 꺼내서 주입 해줌

@AuthenticationPrincipal(expression=”account”) Account account

CurrentUser Annotation을 만들어서 Annotation 간추리기

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "account")
public @interface CurrentUser {
}

커스텀 애노테이션을 만들면

anonymousUser인 경우에는 Authentication 객체가 들고 있던 Principal이 문자열 'anonymousUser' 임

Account 객체를 가지고 있는 AccountAdapter 객체가 아니라 에러가 발생함

getEvent 컨트롤러로 확인

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

@CurrentUser 애노테이션 표현식 수정

현재 인증 정보가 anonymousUse 인 경우에는 null을 보내고 아니면 “account”를 꺼내준다

@CurrentUser Account account

package me.freelife.rest.accounts;

import org.springframework.security.core.annotation.AuthenticationPrincipal;

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)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {
}

manager 정보를 생성

EventController에서 Event를 생성할 때 manager 정보를 생성

조회 API 개선

현재 조회하는 사용자가 owner인 경우에 update 링크 추가 (HATEOAS)

Event 한건 조회 API에 update 링크 추가

@CurrentUser Account currentUser 를 받아와서 현재의 event manager와 동일하면 업데이트 링크 추가

if(event.getManager().equals(currentUser)) {
    eventResource.add(linkTo(EventController.class).slash(event.getId()).withRel("update-event"));
}

수정 API 개선

현재 사용자가 이벤트 owner가 아닌 경우에 403 에러 발생

Event 수정 API에 예외처리

manager 와 현재 유저정보가 다를 경우 인가되지 않았으므로 HttpStatus.UNAUTHORIZED 리턴 처리

if(!existingEvent.getManager().equals(currentUser)){
    return new ResponseEntity(HttpStatus.UNAUTHORIZED);
}

EventController 개선된 로직 전체


...

import me.freelife.rest.accounts.Account;
import me.freelife.rest.accounts.CurrentUser;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {

    ...

    /**
     * Event Create API
     * @param eventDto
     * @param errors
     * @return
     */
    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors, @CurrentUser Account currentUser) {

        ...

        event.update(); //저장하기 전에 유료인지 무료인지 여부 업데이트
        event.setManager(currentUser); //manager 정보 생성

        ...

    }

    /**
     * Event List Select API
     * @param pageable
     * @param assembler
     * @return
     */
    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler, @CurrentUser Account account) {
        //현재 사용자 인증정보 가져오기
        Authentication  authentication = SecurityContextHolder.getContext().getAuthentication();

        ...

        if(account != null) {
            // 유저가 있으면 EventController에 create-event 링크를 추가
            pagedResources.add(linkTo(EventController.class).withRel("create-event"));
        }
        return ResponseEntity.ok(pagedResources);
    }

    /**
     * Event One Select API
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    public ResponseEntity getEvent(@PathVariable Integer id, @CurrentUser Account currentUser) {
        // Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        ...

        Event event = optionalEvent.get();
        EventResource eventResource = new EventResource(event);
        eventResource.add(new Link("/docs/index.html#resources-events-get").withRel("profile"));
        if(event.getManager().equals(currentUser)) {
            eventResource.add(linkTo(EventController.class).slash(event.getId()).withRel("update-event"));
        }
        return ResponseEntity.ok(eventResource);
    }

    @PutMapping("/{id}")
    public ResponseEntity updateEvent(@PathVariable Integer id, @RequestBody @Valid EventDto eventDto, Errors errors, @CurrentUser Account currentUser) {

        ...

        Event existingEvent = optionalEvent.get();
        if(!existingEvent.getManager().equals(currentUser)){
            return new ResponseEntity(HttpStatus.UNAUTHORIZED);
        }

        ...

    }

EventControllerTests 에 테스트 코드 추가

@Test
@TestDescription("30개의 이벤트를 10개씩 두번째 페이지 조회하기 인증정보 가져오기")
public void queryEventsWithAuthentication() throws Exception {
    //Given
    IntStream.range(0, 30).forEach(this::generateEvent);

    // When & Then
    this.mockMvc.perform(get("/api/events")
            .header(HttpHeaders.AUTHORIZATION, getBearerToken())
            .param("page", "1")
            .param("size", "10")
            .param("sort", "name,DESC"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("page").exists())
            .andExpect(jsonPath("_embedded.eventList[0]._links.self").exists())
            .andExpect(jsonPath("_links.self").exists())
            .andExpect(jsonPath("_links.profile").exists())
            .andExpect(jsonPath("_links.create-event").exists())
            .andDo(document("query-events"))
    ;
}
728x90

댓글

💲 추천 글