REST API - REST API 보안 적용

박상훈·2022년 4월 30일
0
post-thumbnail

5-1.Account 도메인 추가

Account 도메인, Role enum 클래스 추가

@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;
}

public enum AccountRole {
    ADMIN, USER
}

Event 클래스 연관관계 코드 추가

@ManyToOne
private Account manager;

5-2.스프링 시큐리티 설정


웹 시큐리티, 메서드 시큐리티 중 웹 시큐리티, OAUTH2 인증

oauth2 maven 추가

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

Acoount Service, Repository 추가

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

    @Override
    public UserDetails loadUserByUsername(String username) {
        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(e -> new SimpleGrantedAuthority("ROLE_" + e.name())).collect(Collectors.toSet());
    }
}

public interface AccountRepository extends JpaRepository<Account, Integer> {
    Optional<Account> findByEmail(String username);
}

테스트 클래스 추가

@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    @Test
    void findByUsername() {
        //Given
        String username = "dcun@rest.api";
        String password = "dcun";
        Account account = Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        this.accountRepository.save(account);

        //When
        UserDetailsService userDetailsService = this.accountService;
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        //Then
        assertThat(userDetails.getPassword()).isEqualTo(password);
    }
}

5-3.예외 테스트


강의 영상 junit 4 기준 테스트 코드
아래 코드에서 중요한건 ExpectedException 필드의 접근제한자,
@Test 코드안에 //Expected, //When 의 순서가 변경되면 테스트에 통과하지 못한다
객체 이름 그대로 예외를 예상하는 것이기 때문에 예외에 대한 정보를 미리 설정하고
When 이 진행되어야 한다

@SpringBootTest
@ActiveProfiles("test")
class AccountServiceTest {
	@Rule
    public ExpectedException expectedException = ExpectedException.none();

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    @Test
    void expectedExceptionFindByUsername() {
    	//Expected
        String username = "random@email.com";
        expectedException.expect(UsernameNotFoundException.class);
        expectedException.expectMessage(Matchers.containsString(username));
        
        //When
        accountService.loadUserByUsername(username);
    }
}

junit 5 기준 테스트 코드
junit 4 에서 사용했던 주석, 예상예외 객체 코드 -> assertThrows 로 대체
상속 계층의 예외들까지 테스트를 통과하므로 유의해야 한다 참조

@Test
void expectedExceptionFindByUsername() {
    String username = "dcun@rest.rest";
    UsernameNotFoundException exceptionWasExpected = assertThrows(UsernameNotFoundException.class, () -> {
        this.accountService.loadUserByUsername(username);
    }, "UsernameNotFoundException was expected");

    assertThat(exceptionWasExpected.getMessage()).isEqualTo(username);
}

5-4.스프링 시큐리티 기본 설정


DelegatingPasswordEncoder 빈 등록할 때 이미 빈으로 등록했던 ModelMapper 를 같이
Config 클래스에 분리 작업
createDelegatingPasswordEncoder 메서드 안에 내용을 확인하면 prefix 로 인코딩 명이
들어가니 실제 패스워드 인코딩에 어떤 작업을 행한지 확인 가능

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

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

기본적인 시큐리티 설정
PathRequest.toStaticResources().atCommonLocations() 정적 자원에 주로 사용하는 경로들이
들어가 있으며 개별적으로 설정하지 않고 한번에 ignoring 가능하다
아래 코드는 다른 클래스에서 사용하기 위해 authenticationManager 를 빈으로 등록하고
빈으로 등록할 때 내가 작성한 service, encoder 를 authenticationManager 에 설정하였다

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


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/docs/index.html").anonymous()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

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


AppConfig 클래스에 ruuner 추가

어플리케이션이 실행될 때 runner 를 통해서 acoount 를 생성하고 로그인 페이지에 입력 후
시큐리티 인증 및 진입하도록 설정

@Bean
public ApplicationRunner applicationRunner() {
    return new ApplicationRunner() {
        @Autowired
        AccountService accountService;

        @Override
        public void run(ApplicationArguments args) throws Exception {
            Account account = Account.builder()
                    .email("dcun@email.com")
                    .password("dcun")
                    .build();
            accountService.saveAccount(account);
        }
    };
}

HttpSecurity 인증 설정

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

AccountService account 등록 시 패스워드 인코딩 작업 코드 추가

@Autowired
PasswordEncoder passwordEncoder;

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

테스트

assertThat(passwordEncoder.matches(password, userDetails.getPassword())).isTrue();

5-6.스프링 시큐리티 OAuth2 인증 서버 설정


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

oauth2 토큰 테스트

client : oauth2 서버 정보

class AuthServerConfigTest extends BaseControllerTest {

    @Autowired
    AccountService accountService;

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

        //When
        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());
    }
}

테스트 response body

bearer : 전달자, 토큰 타입

{
  "access_token":"KVkl-mVhuKQKcb7Y-hxPZBfhTCg",
  "token_type":"bearer",
  "refresh_token":"qm7zoWvLRpDltkTWbUDt6fAosVY",
  "expires_in":599,
  "scope":"read write"
}

5-7.리소스 서버 설정


@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/**")
                    .anonymous()
                .anyRequest()
                    .authenticated()
            .and()
            .exceptionHandling()
                .accessDeniedHandler(new OAuth2AccessDeniedHandler());
    }
}

테스트

perform 에 대한 응답에서 인증 토큰만 리턴하도록 코드 추가

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

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

    //When
    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 jackson2JsonParser = new Jackson2JsonParser();
    return jackson2JsonParser.parseMap(responseBody).get("access_token").toString();
}

perform - GET 을 제외한 요청에 인증 토큰 전달

.header(HttpHeaders.AUTHORIZATION, getBearerAccessToken())

이슈

모든 테스트를 한번에 진행할 때 유니크하지 않은 값을 인서트 하며 단 건 조회 시 여러개가 조회되며 에러 발생
jnit 4 : @Before, junit 5 : @BeforeEach

@BeforeEach
public void initEach() {
    eventRepository.deleteAll();
    accountRepository.deleteAll();
}

5-8.문자열 외부 설정으로 빼내기


application-properties 자동 완성 : processor 추가, build project
테스트 클래스에 하드 코딩들 빈 주입 받아서 적용

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>
@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;
}

5-9.이벤트 API 점검


특별한 내용 없으며 포스트맨 사용으로 테스트 진행

5-10.현재 사용자 조회


SecurityContextHolder 에서 사용자 정보 조회

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        User principal = (User) authentication.getPrincipal();

@AuthenticationPrincipal 주석으로 사용자 정보 조회
expression 에 삼항연산자는 anonymousUser 인 경우 객체를 리턴하지 않고
문자열이 반환되기 때문에 null 로 리턴하기 위함

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {}

사용자 정보를 스프링 시큐리티 User 타입으로 반환하기 위한 adapter

public class AccountAdapter extends User {
    private Account account;


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

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

    public Account getAccount() {
        return account;
    }
}

5-11.출력 값 제한하기


documents 에 관리자에 대한 계정 정보 노출 제거

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

0개의 댓글