포스트

REST API(13) REST API 보안 적용(1) - Spring Security 기본 설정 및 적용

REST API 보안 적용

우리가 지금까지 구현한 API는 전혀 완벽하지 않다. 가장 큰 이유중 하나가 인증이 없다는 것이다.
예를 들어, 이벤트를 만들으려면 적어도 이 시스템에서 인증이 된 사용자가 만들 수 있어야한다. 하지만, 지금의 API는 아무나 이벤트를 만들 수 있다. 또, 이벤트를 수정함에 있어서도 이벤트를 만든 사람만 수정할 수 있어야하나, 이 또한 누구든 수정할 수 있다.
그래서 이제부터 인증 시스템을 도입해보자.


구현 Package 및 Class


Account 도메인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package me.hantomas.restapi.accounts;

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

  • @ElementCollection(fetch = FetchType.EAGER) : 하나의 enum이 아닌, 여러개의 enum을 가질 수 있으므로 @ElementCollection으로 선언한다. fetch의 기본값은 lazy인데, enum값이 소수이기도 하고, 매번 account정보를 가져올 때 마다 필요한 정보이므로 fetch = FetchType.EAGER로 선언한다.
    • @ElementCollection : 컬렉션 객체임을 JPA가 알 수 있게 하게 한다. 엔티티가 아닌 값 타입, 임베디드 타입에 대한 테이블을 생성하고 1대다 관계로 다룬다.

AccountRole

1
2
3
4
5
6
package me.hantomas.restapi.accounts;

public enum AccountRole {
    ADMIN, USER
}

Event

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package me.hantomas.restapi.events;

import lombok.*;
import me.hantomas.restapi.accounts.Account;

import javax.persistence.*;
import java.time.LocalDateTime;

@Builder @AllArgsConstructor @NoArgsConstructor
@Getter @Setter @EqualsAndHashCode(of = "id")
@Entity
public class Event {
    @Id @GeneratedValue
    private Integer id;
    private String name;
    private String description;
    private LocalDateTime beginEnrollmentDateTime;
    private LocalDateTime closeEnrollmentDateTime;
    private LocalDateTime beginEventDateTime;
    private LocalDateTime endEventDateTime;
    private String location;
    private int basePrice;
    private int maxPrice;
    private int limitOfEnrollment;
    private boolean offline;
    private boolean free;
    @Enumerated(EnumType.STRING)
    private EventStatus eventStatus = EventStatus.DRAFT;
    @ManyToOne  // 추가
    private Account manager;

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

  • Event에서 Account를 단방향으로 참조할 수 있도록 @ManyToOne 선언
    • 이벤트에 대한 관리자.

Spring Security 적용

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

  • 웹 시큐리티 : 웹 요청에 보안 인증 (Filter 기반 시큐리티)
  • 메소드 시큐리티 : 웹과 상관없이 어떤 메소드가 호출되었을 때 인증 또는 권한 확인
    공통적으로 이 두 가지 기능 모두 Security Interceptor 라는 인터페이스를 통해서 기능을 제공한다.
  • 리소스에 접근을 허용할지 안할지 결정하는 로직이 들어가 있다.

image
Security Interceptor가 동작하는 기본적인 흐름 다음과 같다.

  1. 요청이 왔을 때, 이 요청을 Servlet Filter가 가로채서, Spring Bean에 등록되어 있는 Filter SecurityInterceptor 쪽으로 보낸다.
  2. Filter SecurityInterceptor가 그 요청을 보고, 이 요청에 Security Filter를 적용해야하는지 안해야하는지 판단한다.
  3. 적용을 해야한다면, 비로소 Security Intercepter에 들어가게 된다.
  4. Security Intercepter에 들어 왔다면, 인증 정보를 우선 확인한다.
  5. 인증 정보를 SecurityContextHolder 넣어 놓는데,
    • 꺼낼 정보가 있으면, 인증된 사용자가 있다.
    • 없으면, 인증을 한 적이 없는 것이다.
  6. SecurityContextHolder인증 정보가 없다면, AuthenticationManager를 사용해서 로그인을 한다.
    • AuthenticationManager에는 사용하는 주요한 인터페이스가 두가지 있는데
      • UserDetailsService
        • 입력받은 username에 해당하는 password를 DB에서 꺼내온다.
      • PasswordEncoder
        • UserDetailsService읽어온 password가 사용자가 입력한 것과 같은지 검사.
        • 같으면 로그인이 된 것이고, 그럼 Authentication객체를 만들어서SecurityContextHolder 저장
  7. SecurityContextHolder를 통해 인증이 되었다면, AccessDecisionManager를 통해 요청한 리소스에 접근할 권한이 적절한지 확인한다.
    • 보통 사용자의 AccountRole로 확인한다.

pom.xml 의존성 추가

1
2
3
4
5
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.0.RELEASE</version>
</dependency>

AccountRepository

1
2
3
4
5
6
7
8
9
10
11
package me.hantomas.restapi.accounts;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Optional;

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


AccountService(UserDetailsService 구현)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package me.hantomas.restapi.accounts;

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

  • @Service어노테이션을 붙여서 빈으로 등록해준다.
  • UserDetailsService인터페이스를 상속받아 UserDetailsloadUserByUsername()를 구현해준다.
  • UserDetails타입을 보면 User라는 클래스가 있는데, 이를 사용해서 구현하면, 전체 인터페이스를 다 구현하지 않아도 된다.
    • Spring Security가 제공하는 Useremail,password,authorities(권한)을 담아 리턴한다.
      • authorities(권한)"ROLE_ + USER/ADMIN 형식으로 바꿔준다.

AccountServiceTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package me.hantomas.restapi.accounts;

import org.assertj.core.api.Assertions;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Set;


import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Fail.fail;

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

    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;
    @Test
    public void findByUsername(){
        // Given
        String password = "taejun";
        String username = "taejun@emai.com";
        Account account = Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN,AccountRole.USER))
                .build();
        this.accountRepository.save(account);

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

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

예외 테스트

방법 (1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// import 생략
import org.springframework.security.core.userdetails.UsernameNotFoundException;


@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {
   
    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;
    @Test
    public void findByUsername(){
       // 생략
    }
    
    @Test(expected = UsernameNotFoundException.class) // 예외 테스트 방법 1
    public void findByUsernameFail(){
        String username = "random@email.com";
        accountService.loadUserByUsername(username);
    }
}

image

  • 예외 타입만 확인 가능

방법 (2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// import 생략
import org.springframework.security.core.userdetails.UsernameNotFoundException;


@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {
   
    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;
    @Test
    public void findByUsername(){
       // 생략
    }
    
    @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);
        }

    }
}

image

  • 에러 객체를 직접 받아오기 때문에 훨씬 더 많은 에러 정보를 확인해 볼 수 있다.
  • 코드 양이 많아진다.

방법 (3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// import 생략
import org.hamcrest.Matchers; // 추가
import org.junit.Rule;  // 추가


import org.springframework.security.core.userdetails.UsernameNotFoundException;


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

    @Rule
    public ExpectedException expectedException = ExpectedException.none();
   
    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;
    @Test
    public void findByUsername(){
       // 생략
    }
    
    @Test // 수정
    public void findByUsernameFail(){
        String username = "random@email.com";
        // Expected
        expectedException.expect(UsernameNotFoundException.class);
        expectedException.expectMessage(Matchers.containsString(username));

        // When
        accountService.loadUserByUsername(username);

    }
}

image

  • 코드는 간결하면서 예외 타입과 메시지 모두 확인 가능
  • 예상되는 예외를 먼저 선언해주어야 한다.

Spring Security 기본 설정

Spring Security를 의존성에 추가한 순간, 우리가 EventControllerTests에 구현했던 모든 테스트들은 다음과 같이 깨진다.
image

그 이유는 모든 요청들이 인증을 필요로 하게되기 때문이다.
Spring Security가 의존성에 들어있으면, SpringBoot는 Spring Security용 자동설정을 적용해준다. 그 자동 설정에 의하면, 모든 요청은 인증을 필요로하고, Spring Security가 사용자 하나를 임의로 만들어준다.

Spring Security 기본설정을 다음과 같이 구현해보자.

  • 시큐리티 필터를 적용 X
    • /docs/index.html
  • 로그인 없이 접근 가능
    • GET /api/events
    • GET /api/events/{id}
  • 로그인 해야 접근 가능
    • POST /api/events
    • PUT /api/events/{id}
  • 스프링 시큐리티 OAuth 2.0
    • AuthorizationServer: OAuth2 토큰 발행(/oauth/token) 및 토큰 인증(/oauth/authorize)
      • Oder 0 (리소스 서버 보다 우선 순위가 높다.)
    • ResourceServer: 리소스 요청 인증 처리 (OAuth 2 토큰 검사)
      • Oder 3 (이 값은 현재 고칠 수 없음)

AppConfig

우선 환경설정 파일을 모아놓을 configs Package를 만들어준다.
AppConfig class 파일을 만들어 애플리케이션용 환경설정을 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package me.hantomas.restapi.configs;

import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class AppConfig {

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

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

  • RestApiApplication 애플리케이션 실행파일에 Bean으로 등록했던 ModelMapper를 옮겨준다.
  • createDelegatingPasswordEncoder()는 Spring Security 최신버전에 들어간 다양한 인코딩 타입을 지원하는 PasswordEncoder다. 다음과 같은 prefix를 제공한다.이를 통해 어떠한 방식으로 인코딩 된건지 알 수 있다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    // ...
    encoders.put(encodingId, new BCryptPasswordEncoder());
    encoders.put("ldap", new LdapShaPasswordEncoder());
    encoders.put("MD4", new Md4PasswordEncoder());
    encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
    encoders.put("noop", NoOpPasswordEncoder.getInstance());
    encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
    encoders.put("scrypt", new SCryptPasswordEncoder());
    encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
    encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
    encoders.put("sha256", new StandardPasswordEncoder());
    // ...
    

SecurityConfig

Spring Security 환경설정 파일을 만들어 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package me.hantomas.restapi.configs;

import me.hantomas.restapi.accounts.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;

@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를 상속받는 순간, SpringBoot가 제공해주는 SpringSecurity 설정은 더이상 적용되지 않는다.
  • TokenStore : Oauth토큰을 저장하는 곳. 저장소는 InMemoryTokenStore를 사용한다.
  • AuthenticationManager를 다른 곳에서도 참조할 수 있도록 Bean으로 노출시켜 주어야 한다.
  • AuthenticationManager를 어떻게 만들지를 정의해주기 위해 AuthenticationManagerBuilder를 재정의
    • userDetailsService는 내가 구현한 accountService,
    • passwordEncoder는 내가 구현한 passwordEncoder를 사용한다.
  • WebSecurity를 재정의하여 ,Spring Security Filter를 적용할지 말지를 구현
    • web.ignoring().mvcMatchers("/docs/index.html"); : "/docs/index.html"으로 들어오는 요청은 무시한다.
    • web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations()); : SpringBoot가 제공해주는 static 리소스들에 대한 위치를 다 가져와서, 이 위치들에는 Spring Security가 적용되지 않도록 구현.
1
2
3
4
5
6
7
8
@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/docs/index.html").anonymous()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).anonymous()
        ;

    }
  • Spring Security Filter를 적용하되, http로 Filtering하는 방법이다.
    • http.authorizeRequests().mvcMatchers("/docs/index.html").anonymous()
      .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).anonymous()
      : 요청에 필요한 인증이 anonymous다. 👉 아무나 접근할 수 있는 요청이 된다.
  • 하지만 이 방법은 Spring Sercurity Filter Chain을 타게 되면서, 서버가 하는 일이 많아진다.
  • 따라서, static 리소스들의 경우에는 WebSecurity에서 Filtering 해주는 것이 좋다.

결과

Config 설정 완료 후, 프로그램을 실행시켜보면 다음과 같이 문서파일 index.html에 접근할 수 있음을 확인할 수 있다.

적용 전

image

적용 후

image


이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.