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
라는 인터페이스를 통해서 기능을 제공한다. - 리소스에 접근을 허용할지 안할지 결정하는 로직이 들어가 있다.
Security Interceptor
가 동작하는 기본적인 흐름 다음과 같다.
- 요청이 왔을 때, 이 요청을
Servlet Filter
가 가로채서, Spring Bean에 등록되어 있는 Filter SecurityInterceptor
쪽으로 보낸다. Filter SecurityInterceptor
가 그 요청을 보고, 이 요청에 Security Filter를 적용해야하는지 안해야하는지 판단한다.- 적용을 해야한다면, 비로소
Security Intercepter
에 들어가게 된다. Security Intercepter
에 들어 왔다면, 인증 정보를 우선 확인한다.- 인증 정보를
SecurityContextHolder
넣어 놓는데,- 꺼낼 정보가 있으면, 인증된 사용자가 있다.
- 없으면, 인증을 한 적이 없는 것이다.
SecurityContextHolder
인증 정보가 없다면, AuthenticationManager
를 사용해서 로그인을 한다.AuthenticationManager
에는 사용하는 주요한 인터페이스가 두가지 있는데UserDetailsService
- 입력받은 username에 해당하는 password를 DB에서 꺼내온다.
PasswordEncoder
UserDetailsService
읽어온 password가 사용자가 입력한 것과 같은지 검사.- 같으면 로그인이 된 것이고, 그럼
Authentication
객체를 만들어서SecurityContextHolder
저장
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
인터페이스를 상속받아 UserDetails
의 loadUserByUsername()
를 구현해준다.UserDetails
타입을 보면 User
라는 클래스가 있는데, 이를 사용해서 구현하면, 전체 인터페이스를 다 구현하지 않아도 된다.- Spring Security가 제공하는
User
에 email
,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);
}
}
|
방법 (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);
}
}
}
|
- 에러 객체를 직접 받아오기 때문에 훨씬 더 많은 에러 정보를 확인해 볼 수 있다.
- 코드 양이 많아진다.
방법 (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);
}
}
|
- 코드는 간결하면서 예외 타입과 메시지 모두 확인 가능
- 예상되는 예외를 먼저 선언해주어야 한다.
Spring Security 기본 설정
Spring Security를 의존성에 추가한 순간, 우리가 EventControllerTests
에 구현했던 모든 테스트들은 다음과 같이 깨진다.
그 이유는 모든 요청들이 인증을 필요로 하게되기 때문이다.
Spring Security가 의존성에 들어있으면, SpringBoot는 Spring Security용 자동설정을 적용해준다. 그 자동 설정에 의하면, 모든 요청은 인증을 필요로하고, Spring Security가 사용자 하나를 임의로 만들어준다.
Spring Security 기본설정을 다음과 같이 구현해보자.
- 시큐리티 필터를 적용 X
- 로그인 없이 접근 가능
- 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 토큰 검사)
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
에 접근할 수 있음을 확인할 수 있다.
적용 전
적용 후