[스프링 기반 REST API 개발] 05. REST API 보안 적용

hh·2023년 12월 23일
0

Spring

목록 보기
6/6
post-thumbnail

05. REST API 보안 적용

Account 도메인 추가

4장까지 구현한 이벤트 API는 인증과 관련된 부분이 없기 때문에 OAuth2 로 인증을 추가하려고 한다.

이벤트 API에서 인증은 인증된 사용자가 이벤트를 생성 및 수정할 수 있도록 하기 위해서 필요하다.

먼저, Account 도메인을 추가해 보자.

// Account.class

import lombok.*;

import javax.persistence.*;
import java.util.Set;

@Entity
@Getter @Setter @EqualsAndHashCode(of = "id")
@Builder @NoArgsConstructor @AllArgsConstructor
public class Account {

    @Id @GeneratedValue
    private Integer id;

    private String email;

    private String password;

    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set<AccountRole> roles;
}
// AccountRole.java

public enum AccountRole {
    ADMIN, USER
}

이렇게 사용자를 나타내는 Account 도메인을 추가하면, Event에 owner 를 추가할 수 있다.

// Event.java

@Builder @AllArgsConstructor @NoArgsConstructor
@Getter @Setter @EqualsAndHashCode(of = "id")
@Entity
public class Event {

	// ... (생략) ...

    @ManyToOne
    private Account manger;  
    
    // ... (생략) ...
}

스프링 시큐리티 적용

스프링 시큐리티 는 기능을 크게 두 가지로 나눌 수 있다.

  1. 웹 시큐리티 : 웹 요청에 보안 인증 (Filter 기반 시큐리티)
  2. 메소드 시큐리티 : 웹과 상관없이 어떤 메소드가 호출되었을 때 인증 또는 권한 확인

공통적으로 이 두 가지 기능 모두 Security Interceptor 라는 인터페이스를 통해서 기능을 제공한다.

5장에서는 웹 시큐리티를 사용할 것이다.
먼저, pom.xml 에 의존성을 추가해야 한다.

<dependency>
	<groupId>org.springframework.security.oauth.boot</groupId>
	<artifactId>spring-security-oauth2-autoconfigure</artifactId>
	<version>2.1.0.RELEASE</version> 
</dependency>

예외 테스트

UserDetailsService 인터페이스의 구현체인 AccountService는 다음과 같다.

// AccountService.java

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

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

@Service
public class AccountService implements UserDetailsService {
    @Autowired
    AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findbyEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return new User(account.getEmail(), account.getPassword(), authorities(account.getRoles()));
    }

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

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Integer> {

    Optional<Account> findByEmail(String username);
}

예외 테스트를 다음과 같이 작성해 보자.
테스트는 정상적으로 수행되지만, 예외의 타입만 확인이 가능하다.

// AccountServiceTest.java

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    // ... (생략) ...

    @Test(expected = UsernameNotFoundException.class)
    public void findByUsernameFail() {
        String username = "random@email.com";
        accountService.loadUserByUsername(username);
    }
}

따라서 예외 타입과 메시지를 확인하고 싶다면 try-catch 를 활용할 수 있다.

// AccountServiceTest.java

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    // ... (생략) ...

    @Test
    public void findByUsernameFail() {
        String username = "random@email.com";
        try {
            accountService.loadUserByUsername(username);
            fail("supposed to be failed");
        } catch (UsernameNotFoundException e) {
            assertThat(e.getMessage()).containsSequence(username);
        }
    }
}

마지막으로 junit이 제공하는 ExpectedException 이라는 룰을 활용할 수 있다.

여기서 주의할 점은 예상되는 예외를 먼저 적어줘야 한다는 것이다.

// AccountServiceTest.java

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    // ... (생략) ...

    @Test
    public void findByUsernameFail() {
        // Expected
        String username = "random@email.com";
        expectedException.expect(UsernameNotFoundException.class);
        expectedException.expectMessage(Matchers.containsString(username));

        // When
        accountService.loadUserByUsername(username);
    }
}

스프링 시큐리티 기본 설정

스프링 시큐리티를 의존성에 추가하는 순간 모든 요청들은 다 인증을 필요로 하기 때문에 그동안 만든 EventControllerTest 는 대부분이 실패할 것이다.

따라서 스프링 시큐리티 OAuth 2.0 적용을 위해 AuthorizationServerResourceServer 가 공통으로 사용할 만한 설정을 먼저 추가해야 한다.

먼저, 설정 파일을 모아두는 configs 패키지 생성 후, 설정파일에 다음과 같이 작성하면 된다.

// SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    AccountService accountService;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Bean
    public TokenStore tokenStore() {
        return new InMemoryTokenStore();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(accountService)
                .passwordEncoder(passwordEncoder);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/docs/index.html");
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

}

해당 설정 파일에서 @EnableWebSecurity 어노테이션을 붙이고 WebSecurityConfigurerAdapter를 상속받는 순간 스프링부트가 제공해 주는 스프링 시큐리티 설정은 더 이상 적용되지 않는다.

따라서 우리가 정의한 설정이 이제부터 적용될 것이다.

그리고 메인 애플리케이션에 있던 modelMapper() 빈을 AppConfig.java 로 옮겨준다.

// AppConfig.java

@Configuration
public class AppConfig {

    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

스프링 시큐리티 설정에서 /docs/index.html 로 들어오는 요청은 시큐리티 필터를 적용하지 않도록 했는데, 한 번 애플리케이션을 실행시켜서 확인해 보자.

이때 꼭 도커로 PostgreSQL 컨테이너를 실행해야 한다!!

$ docker run --name rest -p 5432:5432 -e POSTGRES_PASSWORD=pass -d postgres
(애플리케이션 실행할 때 계속 오류가 났었는데... 도커를 실행 안 해서 생긴 문제였다... 🫠)

스프링 시큐리티 폼 인증 설정

SecurityConfig.java 에서 스프링 시큐리티 폼 인증 설정을 할 수 있다.

@Override 
protected void configure(HttpSecurity http) throws Exception {
  http
    .anonymous()
      .and() 
    .formLogin()
      .and() 
    .authorizeRequests()
      .mvcMatchers(HttpMethod.GET, "/api/**").authenticated()
      .anyRequest().authenticated();
}

익명 사용자 방식과 폼 인증 방식을 모두 활성화하여 스프링 시큐리티가 기본 로그인 페이지를 제공한다.

또한, 요청에 인증을 적용하여 /api 이하의 모든 요청에 대해 인증이 필요하게 설정할 수 있다.

이를 확인해보기 위해 AppConfigApplicationRunner() 를 생성하여 애플리케이션이 실행될 때마다 임의로 계정을 생성하도록 코드를 작성했다.

// AppConfig.java

@Configuration
public class AppConfig {

    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }
    
    // ... (생략) ...

    @Bean
    public ApplicationRunner applicationRunner() {

        return new ApplicationRunner() {
            @Autowired
            AccountService accountService;

            @Override
            public void run(ApplicationArguments args) throws Exception {
                Account haewon = Account.builder()
                        .email("haewon@email.com")
                        .password("1234")
                        .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                        .build();

                accountService.saveAccount(haewon);
            }
        };
    }
}

처음 애플리케이션을 실행하고, /api/eventsGET 요청을 보내면 다음과 같이 로그인 화면이 뜨는 것을 확인할 수 있다.

임의로 입력한 이메일과 패스워드를 입력해서 로그인하면, 이벤트를 조회할 수 있다.

스프링 시큐리티 OAuth2 인증 서버 설정

이번에는 AuthServerConfig 파일을 생성하여 OAuth2 인증 서버를 설정해 보자.

스프링 시큐리티 테스트를 위해 의존성 추가를 해야한다.
pom.xml 에 다음 내용을 추가하자.

<dependency>
  	<groupId>org.springframework.security</groupId>
  	<artifactId>spring-security-test</artifactId>
  	<version>${spring-security.version}</version>
	<scope>test</scope> 
</dependency>

먼저, 토큰 발행 테스트를 다음과 같이 작성한 후, 인증 서버를 설정하려고 한다.

// AuthServerConfigTest.java

public class AuthServerConfigTest extends BaseControllerTest {

    @Autowired
    AccountService accountService;

    @Test
    @TestDescription("인증 토큰을 발급 받는 테스트")
    public void getAuthToken() throws Exception {
        // Given
        String username = "haewon2@email.com";
        String password = "12345";
        Account haewon = Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        this.accountService.saveAccount(haewon);

        String clientId = "myApp";
        String clientSecret = "pass";

        this.mockMvc.perform(post("/oauth/token")
                        .with(httpBasic(clientId, clientSecret))
                        .param("username", username)
                        .param("password", password)
                        .param("grant_type", "password"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("access_token").exists());
    }

}

테스트 코드에서 이메일과 패스워드는 AppConfig 에서 사용하지 않았던 새로운 계정으로 생성해야 한다!

인증서버를 설정하는 코드는 다음과 같다

// AuthServerConfig.java

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    PasswordEncoder passwordEncoder;

    @Autowired
    AuthenticationManager authenticationManager;

    @Autowired
    AccountService accountService;

    @Autowired
    TokenStore tokenStore;

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.passwordEncoder(passwordEncoder);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("myApp")
                .authorizedGrantTypes("password", "refresh_token")
                .scopes("read", "write")
                .secret(this.passwordEncoder.encode("pass"))
                .accessTokenValiditySeconds(10 * 60)
                .refreshTokenValiditySeconds(6 * 10 * 60);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(accountService)
                .tokenStore(tokenStore);
    }
}

이제 앞서 작성한 테스트 코드를 실행시켜보면 정상적으로 동작하는 걸 확인할 수 있다.

리소스 서버 설정

이번에는 어떤 외부 요청에 인증을 하는 리소스에 접근을 할 때, OAuth 서버에서 제공하는 토큰 서비스에 요청을 보내서 토큰을 가지고 요청하도록 리소스 서버를 설정할 것이다.

토큰을 발급받는 과정은 클라이언트가 해야하는 일이고, 리소스 서버는 토큰 기반으로 인증 정보가 있는지 없는지 확인해서 접근 제한을 한다.

먼저, ResourceServerConfig 를 생성하여 리소스 서버를 설정해 보자.

// ResourceServerConfig.java

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("event");
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .anonymous()
                    .and()
                .authorizeRequests()
                    .mvcMatchers(HttpMethod.GET, "/api/**")
                        .permitAll()
                    .anyRequest()
                        .authenticated()
                    .and()
                .exceptionHandling() // 접근 권한이 없는 에러 처리
                    .accessDeniedHandler(new OAuth2AccessDeniedHandler());

    }
}

이렇게 설정을 하면, 우리가 앞서 작성했던 이벤트 컨트롤러 테스트는 GET 요청은 모두 성공을 하고, GET 이외의 요청은 모두 실패할 것이다.

따라서 GET 요청을 제외하고 모두 액세스 토큰을 가지고 요청하도록 테스트를 수정해야 한다.

헤더에 다음 정보를 담아 보내도록 코드를 수정하면 된다.

header(HttpHeaders.AUTHORIZATION, getBearerToken())
// EventControllerTests.java

public class EventControllerTests extends BaseControllerTest {

    @Autowired
    EventRepository eventRepository;

    @Autowired
    AccountService accountService;

	@Autowired
    AccountRepository accountRepository;

    @Before
    public void setUp() {
        this.eventRepository.deleteAll();
        this.accountRepository.deleteAll();
    }
    
    // ... (생략) ...

    private String getBearerToken() throws Exception {
        return "Bearer " + getAccessToken();
    }

    private String getAccessToken() throws Exception {
        // Given
        String username = "haewon2@email.com";
        String password = "12345";
        Account haewon = Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        this.accountService.saveAccount(haewon);

        String clientId = "myApp";
        String clientSecret = "pass";

        ResultActions perform = this.mockMvc.perform(post("/oauth/token")
                .with(httpBasic(clientId, clientSecret))
                .param("username", username)
                .param("password", password)
                .param("grant_type", "password"));

        var responseBody = perform.andReturn().getResponse().getContentAsString();
        Jackson2JsonParser parser = new Jackson2JsonParser();

        return parser.parseMap(responseBody).get("access_token").toString();
    }
    
    // ... (생략) ...

}

테스트를 모두 수정하고 나면 다 정상적으로 실행되는 것을 확인할 수 있다! 🤗

문자열을 외부 설정으로 빼내기

지금까지 유저를 생성할 때 builder() 에 직접 문자열을 입력해서 만들었는데, 이렇게 하다보면 동일한 이메일로 여러 유저가 쌓이게 되어 문제가 생길 수 있다.

따라서 email 계정이 유니크하도록 어카운트 도메인을 수정해야 한다.

// Account.java

@Entity
@Getter @Setter @EqualsAndHashCode(of = "id")
@Builder @NoArgsConstructor @AllArgsConstructor
public class Account {

    @Id @GeneratedValue
    private Integer id;

    @Column(unique = true)
    private String email;

    private String password;

    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set<AccountRole> roles;
}

그리고 기본 유저로 AdminUser 를 생성해 보자.

// AppConfig.java

@Configuration
public class AppConfig {

    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public ApplicationRunner applicationRunner() {

        return new ApplicationRunner() {
            @Autowired
            AccountService accountService;

            @Override
            public void run(ApplicationArguments args) throws Exception {
                Account admin = Account.builder()
                        .email("admin@email.com")
                        .password("admin")
                        .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                        .build();
                accountService.saveAccount(admin);

                Account user = Account.builder()
                        .email("user@email.com")
                        .password("user")
                        .roles(Set.of(AccountRole.USER))
                        .build();

                accountService.saveAccount(user);
            }
        };
    }
}

이제 외부 설정으로 기본 유저와 클라이언트 정보를 빼내려면 AppProperties 라는 클래스를 생성해야 한다.

// AppProperties.java

@Component
@ConfigurationProperties(prefix = "my-app")
@Getter @Setter
public class AppProperties {
    @NotEmpty
    private String adminUsername;

    @NotEmpty
    private String adminPassword;

    @NotEmpty
    private String userUsername;

    @NotEmpty
    private String userPassword;

    @NotEmpty
    private String clientId;

    @NotEmpty
    private String clientSecret;
}

그리고 @ConfigurationProperties 를 사용할 수 있도록 다음 의존성을 pom.xml 에 추가하자.

<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

프로젝트를 빌드한 후, application.properties 에서 유저 정보를 입력할 수 있다.

my-app.admin-username=admin@email.com
my-app.admin-password=admin

my-app.user-username=user@email.com
my-app.user-password=user

my-app.client-id=myApp
my-app.client-secret=pass

이제부터는 AppConfig 에서 유저 정보를 하드코딩하지 않고, AppProperties 를 주입받아서 사용할 수 있다.

// AppConfig.java

@Configuration
public class AppConfig {

    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public ApplicationRunner applicationRunner() {

        return new ApplicationRunner() {
            @Autowired
            AccountService accountService;

            @Autowired
            AppProperties appProperties;
            
            @Override
            public void run(ApplicationArguments args) throws Exception {
                Account admin = Account.builder()
                        .email(appProperties.getAdminUsername())
                        .password(appProperties.getAdminPassword())
                        .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                        .build();
                accountService.saveAccount(admin);

                Account user = Account.builder()
                        .email(appProperties.getUserUsername())
                        .password(appProperties.getUserPassword())
                        .roles(Set.of(AccountRole.USER))
                        .build();

                accountService.saveAccount(user);
            }
        };
    }
}

그리고 OAuth 서버 설정에서도 AppProperties 를 주입받아 클라이언트 정보를 사용할 수 있다.

이벤트 API 점검

이번에는 Postman을 이용해서 지금까지 만들었던 이벤트 API를 점검해보려고 한다.

먼저, Authorization에 Type을 Basic Auth 로 하여, clientIdclientSecret 값을 입력한다.

Body에 값을 채워서 POST 요청을 보내면 아래와 같이 액세스 토큰을 발급받을 수 있다.

하지만, 처음 API 설계를 할 때 로그인을 한 상태에 응답으로 이벤트를 생성할 수 있는 API 링크를 포함하려고 계획했었는데, 현재 그 부분이 구현되어 있지 않다.

추가적으로 로그인을 했을 때, 이벤트 Manager인 경우에는 이벤트 수정 링크를 제공해야 한다.

현재 사용자 조회

현재 사용자 정보를 조회하여 이벤트 API에서 부족한 부분을 마저 구현하려고 한다.

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

이벤트 컨트롤러에 다음 코드를 추가하여 인증 정보를 꺼내올 수 있다.

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

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

하지만, 우리의 목적은 현재 사용자가 있으면 Account 로 받아오는 것이기 때문에 다른 방법으로 사용자 조회를 해보려고 한다.

스프링 MVC 핸들러 파라미터에 @AuthenticationPrincipal 어노테이션을 사용하면 GET Principal로 리턴 받을 수 있는 객체를 바로 주입받을 수 있다.

// EventController.java

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
    private final EventRepository eventRepository;
    private final ModelMapper modelMapper;
    private final EventValidator eventValidator;
    
    public EventController(EventRepository eventRepository, ModelMapper modelMapper, EventValidator eventValidator) {
        this.eventRepository = eventRepository;
        this.modelMapper = modelMapper;
        this.eventValidator = eventValidator;
    }
    
    // ... (생략) ...
    
    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler, @AuthenticationPrincipal User user) {

        Page<Event> page = this.eventRepository.findAll(pageable);
        var pageResources = assembler.toModel(page, e -> new EventResource(e));
        pageResources.add(Link.of("/docs/index.html#resources-events-list").withRel("profile"));

        if (user != null) {
            pageResources.add(linkTo(EventController.class).withRel("create-event"));
        }

        return ResponseEntity.ok(pageResources);
    }
    
    // ... (생략) ...
}

usernull 이 아닌 경우 create-event 링크를 추가해주도록 했다.


이벤트를 조회하는 경우는 user가 있는지 없는지만 참조하면 되지만, 이벤트를 생성하는 경우에는 현재 사용자 정보를 이벤트에 주입해 주어야 한다.

따라서 AccountAdapter 를 만들어서 User 객체를 바꿀 수 있도록 해야 한다.

// AccountAdapter.java

public class AccountAdapter extends User {

    private Account account;

    public AccountAdapter(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());
    }
}

AccountService 에서 다음과 같이 AccountAdapter 를 리턴하도록 수정하면, 이제 이벤트 컨트롤러에서도 AccountAdapter 를 사용할 수 있게 된다.

// AccountService.java

@Service
public class AccountService implements UserDetailsService {
    @Autowired
    AccountRepository accountRepository;

    @Autowired
    PasswordEncoder passwordEncoder;

    public Account saveAccount(Account account) {
        account.setPassword(this.passwordEncoder.encode(account.getPassword()));
        return this.accountRepository.save(account);
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));
        return new AccountAdpter(account);
    }
}

CurrentUser 라는 커스텀 어노테이션을 다음과 같이 생성한 후, 이벤트 컨트롤러에서 어노테이션을 사용해 보자.

// CurrentUser.java

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 {

}
// EventController.java

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
    private final EventRepository eventRepository;
    private final ModelMapper modelMapper;
    private final EventValidator eventValidator;
    
    public EventController(EventRepository eventRepository, ModelMapper modelMapper, EventValidator eventValidator) {
        this.eventRepository = eventRepository;
        this.modelMapper = modelMapper;
        this.eventValidator = eventValidator;
    }
    
    // ... (생략) ...
    
    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler, @CurrentUser Account account) {

        Page<Event> page = this.eventRepository.findAll(pageable);
        var pageResources = assembler.toModel(page, e -> new EventResource(e));
        pageResources.add(Link.of("/docs/index.html#resources-events-list").withRel("profile"));

        if (account != null) {
            pageResources.add(linkTo(EventController.class).withRel("create-event"));
        }

        return ResponseEntity.ok(pageResources);
    }
    
    // ... (생략) ...
}

이렇게 수정하고 나면, 모든 테스트가 정상적으로 잘 동작한다.

출력값 제한하기

이벤트 생성 API를 개선하여 응답에서 owner의 id 만 보내도록 할 수 있다.

AccountSerializer 를 새로 생성하여 다음과 같이 코드를 작성하면, id 만 기록 된다.

// AccountSerializer.java

public class AccountSerializer extends JsonSerializer<Account> {
    @Override
    public void serialize(Account account, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
        gen.writeStartObject();
        gen.writeNumberField("id", account.getId());
        gen.writeEndObject();
    }
}

그리고 이벤트 도메인에서 Manager에 다음 어노테이션을 붙이면 된다.

// Event.java

@Builder @AllArgsConstructor @NoArgsConstructor
@Getter @Setter @EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // ... (생략) ...

    @ManyToOne
    @JsonSerialize(using = AccountSerializer.class)
    private Account manger;

    public void update() {
        // Update free
        if (this.basePrice == 0 && this.maxPrice == 0) {
            this.free = true;
        } else {
            this.free = false;
        }

        // Update offline
        if (this.location == null || this.location.isBlank()) { // 실제 비어있는지 여부
            this.offline = false;
        } else {
            this.offline = true;
        }
    }
}

이렇게 출력값이 id 로만 제한된 것을 확인할 수 있다!

IntelliJ 명령어 정리 (macOS)

💡 [cmd + opt + M] : extract method
💡 [cmd + opt + L] : 코드 포맷팅

마치며

2019년도에 만들어진 강의라 그런지 2023년에는 바뀐 부분이 많아 오류를 해결하는 것이 너무너무 힘들었다...

그래도 강의를 들으며 REST API가 무엇인지, TDD에 조금 더 익숙해질 수 있었다!


인프런 백기선님의 스프링 기반 REST API 개발을 기반으로 작성했습니다.

profile
EWHA CSE 21

0개의 댓글