[Spring-REST-API] 3. 인증 및 스프링 시큐리티로 보안 설정

문제점

  • 현재 아무나 CREATE UPDATE가 가능
  • 인증 시스템 도입 필요
  • 스프링 시큐리티 Oauth2 인증
  • 여러 grant 인증중 password 채택

Account 도메인 추가

User 도메인 ->

  • PostgresQL의 Table의 이름으로는 쓸 수 없음
  • user를 쓰고 @Table(“다른이름”)을 사용하면 되긴 하는데 굳이;;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//AccountRole.java
public enum AccountRole {
ADMIN,USER
}

//Account.java
@Entity
@Getter @Setter @EqualsAndHashCode(of = "id")
@Builder @AllArgsConstructor @NoArgsConstructor
public class Account {
@Id @GeneratedValue
private Long id;
private String email;
private String password;
@Enumerated(EnumType.STRING)
@ElementCollection(fetch = FetchType.EAGER)
private Set<AccountRole> roles;
}
  • 설명
    • Account를 간단하게 정의
    • 기본 롬복 애노테이션은 Event를 만들었을때와 동일
    • Role은 간단하게 2가지만 정의하였고 Enumtype을 기본값이 아닌 String으로
    • @ElementCollection
      • role은 하나만 매핑되는 것이 아니므로 여러 Enum이 적용되도록 애노테이션
      • collection이므로 fetch값이 기본값으로 지연로딩인 fetchType.LAZY로 되어 있음
      • 여기선 가져오는 roll양이 매우 적은데다가 매번 Account 부를때마다 필요한 정보
      • 따라서 즉시로딩인 FetchType.EAGER가 좋다

연관관계

  • 이벤트가 작성자 owner로 Account를 가질 수 있다
  • 많은 이벤트를 혼자서 작성할 수 있으므로 Event에서 Acccount로의 관계는
    many-to-one이니 @ManyToOne을 사용할 수 있다

스프링 시큐리티

  • 웹 시큐리티, 메소드 시큐리티로 크게 나눠볼 수 있다
  • 웹 시큐리티 : 웹 요청에 보안 인증
  • 메소드 시큐리티 : 웹과 상관없이 메소드 호출되었을때 권한 확인
  • 둘다 Security Interceptor를 사용해서 기능을 제공
    • 웹 시큐리티인 경우에는 Filter Chain과 연계되어서 사용됨(서블릿)
    • 메소드 시큐리티의 경우에는 AOP를 생각하면 됨, 프록시 객체로 접근과 보안 강제
  • 구현체도 2개: Method SecurityInterceptor, Filter SecurityInterceptor
  • 기본 동작 흐름(웹 요청 예)
    • 요청이 왔을 때 서블릿 필터가 인터셉터
    • 스프링 빈에 등록된 인터셉터로 요청이 감
    • 인터셉터가 요청을 보고 시큐리티 필터를 적용할지 안할지 판단
    • 필터를 적용해야 한다면 인터셉터 내용 적용
    • 인터셉터에서는 SecurityContext Holder라는 ThreadLocal 구현체가 존재
      (기본값이 쓰레드 로컬 구현체이며 다른 구현체로 바꿔 끼울 수도 있음)
    • ThreadLocal : 자바7부터 지원되는 한 쓰레드내에서 공유되는 자원 및 저장소
      –> 메소드에서 값을 넘겨줄떄 파라미터로 값을 넘겨주지 않고 (한 쓰레드라면)
      쓰레드 로컬에 넣어놓고 다른 곳에서 꺼내 쓰는 (DB처럼) 개념
    • 인터셉터에서 SecurityContext Holder에서 인증 정보를 꺼내려고 시도
    • 꺼내는데 성공하면 인증된 사용자가 존재, 없으면 인증된 적이 없는 것
    • 이 경우 AuthenticationManager를 사용해서 로그인 시도
      • 이 때 사용되는 주요 인터페이스 2개가 UserDetailsService, PasswordEncoder
    • AuthenticationManager는 인증을 위한 여러가지 방법이 있는데 가장 기본이 되는 것은
      Basic Authentication이다
      • 인증요청 헤더에 Authentication: basic (user/pass합해서 인코딩한값)
      • 입력을 받으면 UserDetailsService를 사용해서 입력한 user에 대한 password를
        (DB든 아니면 다른곳에 있든)읽어 온다
      • 패스워드 일치 여부는 PasswordEncoder로 검사
      • 매칭이 되면 인증이 되어서 Authentication 객체를 만들어서 위에서 말했던
        SecurityContext Holder에 저장해 놓는다
    • 인증이 되면 AccessDecisionManager를 통해 권한을 확인한다
      • 이것도 여러가지 방법이 있지면 UserRole의 방법을 많이 사용한다
의존성 추가
1
2
3
4
5
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>

테스트부터 작성해보자

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
@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {

@Autowired
AccountService accountService;

@Autowired
AccountRepository accountRepository;

@Test
public void findByUsername() throws Exception{
//Given
String userName = "rkaehdaos@gmail.com";
String password = "123123";
Account account = Account.builder()
.email(userName)
.password(password)
.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
.build();
accountRepository.save(account);
//when
UserDetailsService userDetailsService = accountService;
UserDetails userDetails = userDetailsService.loadUserByUsername(userName);

//then
assertThat(userDetails.getPassword()).isEqualTo(password);
}
}
  • 설명
    • 컨트롤러 테스트의 MockMvc등이 필요없으므로 BaseControllerTest를 상속하지 않음
    • AccountRepository가 필요하다
      1
      2
      3
      public interface AccountRepository extends JpaRepository<Account, Long> {
      Optional<Account> findByEmail(String userName);
      }
    • JpaRepository에 email로 검색하는 메소드 하나를 더 추가하였다
    • NullException을 막기 위해 Optional로 처리

구현시작

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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());
}
}
  • 설명
    • AccountService는 USerDetailsService의 loadUserByUsername를 구현한다
    • username으로 검색하고 없으면 에러던진다
    • 이제 반환된 account를 바탕으로 UserDetails를 만들면 된다
    • UserDetails의 구현체를 검색하면 3개가 나오는데 그중 하나가 User라는 클래스
    • 이 User클래스를 사용하면 전체 인터페이스를 다 구현하지 않아도 되서 편리하다
    • User(이름/패스/authorities)형태로 마지막에 authorities가 들어가야하는데
      GrantedAuthority라고 해서 role을 authorities로 변환해야한다
    • authorities()에서는 각 role의 스트림을 받아서 SimpleGrantedAuthority라는
      콜렉션을 만들어서 반환

추가 테스트

  • 위의 loadUserByUsername를 작성시 테스트가 충분하지 못함

1번째 방법 : expected

1
2
3
4
@Test(expected = UsernameNotFoundException.class)
@TestDescription("Username으로 검색해서 실패")
public void findByUsernameFail() throws Exception{
}
  • 설명
    • 해당 익셉션이 나오면 테스트 성공으로 친다
    • 좋은 테스트? 이경우에는 익셉션 타입말고는 아무것도 검증할 수 없음

2번째 방법 : try - catch

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
@TestDescription("Username으로 검색해서 실패")
public void findByUsernameFail() throws Exception{
try {
//code

//code가 끝나도 예외발생을 안하면 실패처리
Fail("supposed to fail");
} catch(UsernameNotFoundException e){
assertThat(e instanceof UsernameNotFoundException).isTrue();//100%
assertThat(e.getMessage()).containsSequence(username);
}
}
  • 설명
    • try catch를 이용해서 실패잡아냄
    • 예외타입과 메세지를 확인 가능
    • 코드가 복잡하다

3번째 방법 : @Rule ExpectedException

  • Junit에서 제공하는 @Rule에 ExpectedException이라는 Rule이 존재
  • public으로 등록해야함
  • 보통은 ExpectedException.none()으로 비어있는 Exception으로 등록
  • expected이므로 예상되는 예외를 먼저 적어줘야함
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
@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {

@Rule
public ExpectedException expectedException = ExpectedException.none();


@Autowired
AccountService accountService;

@Test
@TestDescription("Username으로 검색해서 실패")
public void findByUsernameFail() throws Exception{

//expected
String userName = "random@gmail.com";
expectedException.expect(UsernameNotFoundException.class);
expectedException.expectMessage(Matchers.containsString(userName));

//when
UserDetails userDetails = accountService.loadUserByUsername(userName);
}
}
  • 설명
    • 코드가 간결하면서 에러타입과 에러메세지 모두 확인가능
    • expected when 위치가 바뀌긴 하지만 이건 어쩔 수 없음

스프링 시큐리티 기본 적용

기본 설정

  • 시큐리티 의존성 추가하면 모든 요청들은 인증이 필요함
  • why? 스프링 부트가 스프링 시큐리티 자동 설정 적용
    • 모든 요청 인증 필요
    • inmemory로 사용자 하나 등록

목표

  • 시큐리티 필터 아예 적용 안하기
    • /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)
      • Order 0 : 리소스 서버보다 높은 우선 순위
    • ResourceServer
      • 리소스 요청 인증 처리 (OAuth 2 토큰 검사)
      • Order 3 : 이 값은 현재 고칠 수 없음

설정1 : AuthorizationServer와 ResourceServer 공통적용 내용

SecurityConfig생성
1
2
3
4
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
  • 설명
    • 설정파일을 모아놓을 configs패키지에 SecurityConfig 자바 설정 파일
    • 자바 설정 파일이므로 @Configuration
    • @EnableWebSecurity + WebSecurityConfigurerAdapter상속
      • 스프링 부트의 자동설정을 하나도 쓰지 않겠다는 말
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration 어플리케이션 설정 파일
public class AppConfig {

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

@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
  • 설명
    • SpringBootRun에 있던 modelMapper 빈 등록 부분 옮김
    • PasswordEncoder 등록
      • createDelegatingPasswordEncoder()
        createDelegatingPasswordEncoder는 앞에 인코딩방법의 prefix를 추가
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        public class PasswordEncoderFactories {
        public static PasswordEncoder createDelegatingPasswordEncoder() {
        String encodingId = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap();
        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());
        return new DelegatingPasswordEncoder(encodingId, encoders);
        }

        private PasswordEncoderFactories() {
        }
        }
        • 최신 스프링 시큐리티에 추가됨
        • password 앞에 암호화 prefix를 붙여줌
        • 어떠한 암호화 인코딩 방식인지 알 수 있음
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
@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 {
//super.configure(auth);
auth.userDetailsService(accountService).passwordEncoder(passwordEncoder);
}

@Override
public void configure(WebSecurity web) throws Exception {
//super.configure(web);
web.ignoring().mvcMatchers("/docs/index.html");
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
  • 설명
    • AccountService
      • 스프링 시큐리티의 AuthenticationManager(위에서 로그인 담당으로 설명햇던)에다가
        설정을 할 UserDetailsService를 SecurityConfig에서 받아온다
      • 여기서는 UserDetailsService를 상속받은 AccountService를 Autowired
    • TokenStore : Oauth 토큰 저장소
      • InMemoryTokenStore (여기서 사용)
      • JdbcTokenStore
      • JwkTokenStore
      • JwtTokenStore
      • RedisTokenStore
    • AuthenticationManager의 빈 노출
      • 다른 AuthorizationServer, ResourceServer에서 참조할 수 있도록 빈으로
        노출 시킬 필요가 있음
      • authenticationManagerBean를 오버라이딩해서 @Bean을 붙이면 빈 등록이 된다
    • 그럼 저 노출되는 AuthenticationManager를 어떻게 만들 것인가?
      • configure(AuthenticationManagerBuilder auth) 오버라이딩
      • userDetailsService에 accountService
      • passwordEncoder에 Appconfig에서 빈등록하고 Autowired로 받은 passwordEncoder
    • 시큐리티 필터 적용 여부
      • configure(WebSecurity web)를 오버라이드
      • 이제 적용된 부분은 시큐리티가 적용되지 않는다
      • PathRequest: 스프링 부트가 제공(서블릿용, 리액티브용 구분)
      • static Resource에 대한 기본 위치를 가져와서 시큐리티 안되도록 설정
    • logging level
      • 개발시 에러 정정이 쉽도록 시큐리티는 디버그 레벨로

다른 방법

  • 위 방법은 시큐리티 필터에서 아예 제외하는 것
    1
    2
    3
    4
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests().mvcMatchers("/docs/index.html").anonymous();
    }
  • WebSecurity 대신 HttpSecurity에서 거르는 방법이 있음
  • HttpSecurity에서 거른다는것은 필터에의해 일단 시큐리티로는 들어온 것
  • 로그를 보면 훨씬 많이 찍힌 것을 볼 수 있다 -> 훨씬 많은 작업
  • 정적 리소스같은 경우는 전부 허용할 거라면 전자의 방법을 적용하는 것이 조금이나마
    일을 줄이는 것이라고 할 수 있다
  • HttpSecurity 재정의시 시큐리티 마음대로 설정 가능
    HttpSecurity 재정의를 이용한 설정 예
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Override
    protected void configure(HttpSecurity http) throws Exception {

    http
    .anonymous()
    .and()
    .formLogin()
    .and()
    .authorizeRequests()
    .mvcMatchers(HttpMethod.GET,"/api/**").anonymous()
    .anyRequest().authenticated()
    ;
    }
  • 설명
    • 여러가지 설정의 예시
    • anonymous()익명선언 가능, formLogin()으로 formLogin사용
    • formLogin하위로 원래 loginPage, passwordParameter등 사용 가능하나
      최신에 기본 인증페이지가 포함되어있으므로 거의 그대로 사용
    • 마지막 밑줄은 /api로 들어오는 모든 GET요청에 대해서는 익명접근을 허용하고
      나머지에는 인증필요하다고 설정한 것이다

AccountService에서 PasswordEncoder 사용

  • 현재 스프링 시큐리티에는 SecurityConfig의 AuthenticationManagerBuilder
    오버라이딩에서 userDetailsService는 accountService, passwordEncoder에도
    passwordEncoder를 설정 하였음
  • 하지만 현재 Account테스트등에서는 Account를 accountRepository를 사용해서 바로
    저장 –> 이경우 passwordEncoding이 전혀 되지 않음
  • 따라서 패스워드 매치업이 불가능
  • 해결책 AccountService에 AccountRepository저장 서비스 메소드를 만들고
    저장시 패스워드 인코딩을 하도록 한다
    AccountService에 추가 하는 부분
    1
    2
    3
    4
    5
    6
    @Autowired  PasswordEncoder passwordEncoder;

    public Account saveAccount(Account account){
    account.setPassword(passwordEncoder.encode(account.getPassword()));
    return accountRepository.save(account);
    }
    테스트 수정 부분
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Test
    public void findByUsername() throws Exception{

    //기존 코드...

    //accountRepository.save(account);
    accountService.saveAccount(account);

    //기존 코드

    //assertThat(userDetails.getPassword()).isEqualTo(password);
    assertThat(passwordEncoder.matches
    (password,userDetails.getPassword())).isTrue();
    }
    설명
    • 기존 테스트에서 위에서 설명한 부분 교체
    • passwordEncoder의 match는 앞에 원 패스워드 뒤에는 인코딩되서 DB에 저장된 값
    • 이경우 앞에것을 인코드해서 뒤의 값과 비교하게 됨

기본 설정외에 서비스를 위해 더 필요한 부분?

  • 사용자 계정 추가 방법
  • email 인증 방법 등

스프링 시큐리니 Oauth2 설정 : 인증 서버 설정

테스트 작성

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
package me.rkaehdaos.springrestapiprojectdemo.configs;

import java.util.Set;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

public class AuthServerConfigTest extends BaseControllerTest {

@Autowired AccountService accountService;

@Test
@TestDescription("인증토큰 발급 받는 테스트")
public void getAuthToken() throws Exception {

//Given
String username = "rkaehdaos@gmail.com";
String password = "ahn";
Account account = Account.builder()
.email(username)
.password(password)
.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
.build();
accountService.saveAccount(account);

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

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())
;
}
}
  • 설명
    • AuthServerConfig가 되면 인증 토큰이 발급 가능해야 함. 그를 위한 테스트

    • 일종의 컨트롤러 테스트이므로 BaseControllerTest 상속

    • 인증을 위해서 account가 필요하므로 account저장을 위한 accountService 주입받음

    • Oauth2가 제공하는 6가지 방법중 2가지 사용예정

      • Grant Type: Password : 최초에 토큰 받을 때 사용
        • 다른 인증과 다르게 1 hop : 요청과 응답이 한쌍이라는 이야기
        • Granty Type: 토큰 받아오는 방법
        • 서비스 오너가 만든 클라이언트에서 사용하는 Grant Type
          (페이스북의 페이스북 앱, 구글이 만든 유트브앱 등)
        • 상세: 레퍼런스
        • 필요내용
          • grant_type=password
          • username, password
          • client_id, client_secret(Optional)
            • 헤더에 Basic Authentication 형태로 넣어 줄 수 있다
          • 나머지는 request Parameter형태로 넣어주면 된다
      • RefreshToken
        • Oauth 토큰 발급 받을 때 RefreshToken 도 같이 발급
        • RefreshToken를 가지고 새로운 Access 토큰을 발급 받을 수 있음
    • 인증 서버 등록시 “/oauth/token”요청을 처리할 수 있는 핸들러가 기본 등록된다

    • 테스트에서 basic Authentication 을 통과하기 위해서 httpBasic을 사용하려면
      의존성 추가가 필요

      시큐리티 테스트 의존성 추가
      1
      2
      3
      4
      5
      6
      <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-test</artifactId>
      <version>${spring-security.version}</version>
      </dependency>

    • 발급되는 엑세스 토큰 형태

1
2
3
4
5
6
{
"access_token": "MTQ0NjOkZmQ5OTM5NDE9ZTZjNGZmZjI3",
"token_type": "bearer",
"expires_in": 3600,
"scope": "create"
}
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
```
설명
- @EnableAuthorizationServer+AuthorizationServerConfigurerAdapter
-->자동설정을 안할 것(확실치 찮음...스프링 부트의 패턴으로 봤을때 아마도)
- configure 구현 3개 전부다 오버라이드로 가져온다
- security configure
- passwordEncoder를 설정한다
- 이미 만들어 둔것이 있으니 @Autowired로 가져와서 설정
- 이 passwordEncoder는 clientSecret를 확인할때 사용
- clientSecret도 인코딩해서 관리하는게 best practice
- client configure
- 클라이언트 설정
- 인메모리용
- grant type 2가지
- 스코프는 클라이언트에서 정의하기 나름
- secret이 클라이언트의 시크릿이므로 encoder로 인코딩 해준다
- accessTokenValiditySeconds: 엑세스 토큰의 유효시간
- 이렇게 해서 인메모리로 하나의 클라인트를 등록 하였음
- inmemory로 User등록과 비슷한 개념
- 이상적인 것은 DB로 .. 나중에 스프링 시큐리티 공부시
- Endpoint configure
- AuthenticationManager,TokenStore, UserDetails 설정 가능
- 이전 빈 설정(SecurityConfig)에서 authenticationManagerBean()에서 @Bean을 해서
빈등록을 하였던 이유가 이렇게 필요할때 AuthenticationManager를 가져오기 위해서
- 빈으로 등록된 AuthenticationManager는 유저 인증 정보를 가지고 있다
- 유저 정보를 확인해야 토큰을 발급 받을 수 있으므로
Endpoint의 AuthenticationManager로 빈으로 등록된 그 AuthenticationManager를
설정한다
- userDetailsService도 빈등록된 accountService를 설정
- tokenStore도 이전 SecurityConfig에서 만들어서 등록한 빈을 가져온다











# 스프링 시큐리니 Oauth2 설정 : 리소스 서버 설정
테스트 수정: GET 을 제외하고 모두 엑세스 토큰을 가지고 요청하도록 테스트 수정

리소스 서버
- 위에서 설정한 Oauth2 서버랑 연동이 되서 사용이 된다
- 어떠한 외부 요청이 리소스에 접근 할 때 인증이 필요하면 OAuth2의 토큰 유효 확인
- 발급이 아닌 인증 정보 유무와 권한만 확인
- 이 예제에서 엄밀히 말하면 리소스 서버는 Event서비스 리소스를 제공하는 서버와 같이
있는게 맞고 인증서버는 사실 따로 분리하는게 맞다
- 작은 서비스에서는 같이 쓰는 경우가 많음
- 여러 팀이 작업하는 경우라면 OAuth2 서버는 따로 빠져나가야 맞다

```java
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("event"); //at least id는 설정
}

@Override
public void configure(HttpSecurity http) throws Exception {
http
//.anonymous() : 잘못된
.permitAll()
.and()
.authorizeRequests()
.mvcMatchers(HttpMethod.GET,"/api/**").anonymous()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler( new OAuth2AccessDeniedHandler());
}
}

설명

  • SecurityConfig가 @EnableWebSecurity+WebSecurityConfigurerAdapter상속하듯이
    @EnableResourceServer+ResourceServerConfigurerAdapter상속함??
  • ResourceServerSecurityConfigurer에서 id를 설정
    • 리소스 서버용 프로퍼티들 추가 가능
    • ResourceServerSecurityConfigurer 설명 보면 적어도 id는 설정하라고 되어있음
    • 그 말 대로 id만 설정하고 나머진 기본 설정 그대로
  • configure(HttpSecurity)
    • 기본 설정처럼 시큐리티 필터 적용 여부 설정
    • 맨 처음 전체에 대해 anonymous허용
    • /api/** 에 대해서 GET은 anonymous, put이나post요청인 경우 인증 필요
    • 인증이 잘못되거나 권한이 없는 경우 예외가 발생하는데 해당 핸들링을
      exceptionHandling에서 설정 가능
    • 예제에서 접근 권한이 없는 경우 OAuth2AccessDeniedHandler로 핸들러 설정
    • OAuth2AccessDeniedHandler는 403응답을 한다(소스 설명에 따르면)

anonymous로 설정하면 anonymous만 된다. 즉 인증을 하지 않은 상태에서만 사용가능하다
인증을 하면 사용할 수 없게 된다

이제 EventConrollerTest를 돌려보면 GET 요청은 성공하고
그 이외의 요청은 실패하게 된다(인증이 필요하므로)
이제 인증이 필요한 접근에 대해 인증 정보를 넣도록 테스트 코드를 수정해야 한다

토큰 정보가 있어야 인증을 할 수 있으니 토큰을 가져와야한다

인증 부분을 추가
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
  //.header부분을 get외의 인증이 필요한 부분에 추가한다
mockMvc.perform(post("/api/events")
.header(HttpHeaders.AUTHORIZATION,"Bearer "+ getBearerToken())
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(eventDto))
)
.andDo(print());
//토큰을 가져오는 부분
private String getBearerToken() throws Exception {
//Given
String username = "ahn@email.com";
String password = "ahn";
Account ahn = Account.builder()
.email(username)
.password(password)
.roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
.build();
Account result = accountService.saveAccount(ahn);
log.info("**test** account id: '{}'", result.getId() );

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

//expected
ResultActions performResult = mockMvc.perform(post("/oauth/token")
.with(httpBasic(clientId, clientSecret))
.param("username", username)
.param("password", password)
.param("grant_type", "password")
);
String responseBody = performResult.andReturn().getResponse().getContentAsString();
Jackson2JsonParser parser = new Jackson2JsonParser();
return parser.parseMap(responseBody).get("access_token").toString();
}

설명

  • GET외에 인증이 필요한 부분에 저 헤더 부분을 추가
  • getBearerToken은 이전getAuthToken에서 그대로 가져와서 마지막만 수정
  • result에서 acces토큰만 가져와서 반환
  • 저러면 이제 인증이 필요한 테스트에서 자동으로 토큰이 추가 되므로 테스트가 성공된다

유니크 에러가 나는 경우가 있는데
이경우 같은 아이디로(같은 이메일로) 만들어서 그러하다

각 테스트는 테스트가 실행되는 동안 테스트 db를 공유하기 떄문에 단건 성공하던 테스트가
한꺼번에 돌릴 경우 중복에러등으로 실패가 될 때도 있다

문제점을 해결하기 위해선 id를 생성할떄 랜덤한 이름으로 생성하게 하거나
각 테스트가 실행시 @Before 메소드에서 각 repository의 deleteAll()을 실행해서
DB를 삭제하고 실행하면 된다

사실 아예 이런 문제가 발생하지 않으려면 중복문제가 일어나지 않도록
유저이름에 unique를 걸어놓고 저장하기 전에 확인하는 로직을 만들어 놓는다
현재는 어차피 inmeoryDB라 어플리케이션 종료시 DB가 내려가므로 AppRunner에서 미리
deleteAll()등을 실행하고 저장하도록 하지 않아도 무방하며 현재 테스트에서는
create-drop을 사용하기 때문에 테스트사이의 @Before 메소드 실행으로 충분

email컬럼에 unique속성
1
2
@Column(unique = true)
private String email;

AppRunner에서 기존 계정을 만드는 부분을 다음과 같이 운영자와 사용자 한 명씩
추가하도록 한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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);
}

특정 설정을 외부 프로퍼티로 빼내보자

@ConfigurationProperties으로 어노테이션 된 아이템들에서
spring-boot-configuration-processor jar를 이용해서 나만의 설정 메타데이터
(configuration metadata)을 손쉽게 생성할 수 있다.
이 jar 파일은 사용자 프로젝트가 컴파일될 때 호출되는 자바 어노테이션 프로세서를
포함하고 있다. 이 프로세서를 사용하기 위해서는 다음의 의존성을 추가해야한다.
(인텔리J에서 그냥 @ConfigurationProperties를 사용하면 의존성 추가방법이 나오는
레퍼런스 페이지에 대한 링크를 안내한다)

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
@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 는 EnableConfigurationProperties로 등록되거나
    스프링 컴포넌트로 마킹되어야 한다
  • prefix는 canocial properties에 따른다. kebab-case(-로 구분되는)의 영문소문자,
    숫자로 이루어지며 처음에는 문자로 시작해야한다. 따라서 원래 쓰는 ‘myApp’으로 하면
    에러가 나므로 my-app으로 하였다
  • 비어있으면 안되므로 @NotEmpty를 붙였다. 해당 애노테이션은
  • 프로퍼티 파일에 작업하면 아직 my-app 자동완성이 되지 않는데, 프로젝트를 빌드하면
    아까 의존성에 추가한 스프링 부트 설정 프로세서가 동작해서 메타데이터가 생성되서
    자동완성이 된다
  • getter,setter가 필요하므로 롬복 애노테이션을 사용
프로퍼티파일에 해당 정보를 기입. 자동완성 되는 부분도 확인
1
2
3
4
5
6
7
#my-app
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

이제 AppRunner에서 계정 생성 부분도 AppProperties를 주입받아서 사용하면된다
이왕 이렇게 된거 중복 코드도 없애도록 했다

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
@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 {
makeUser(appProperties.getAdminUsername(), appProperties.getAdminPassword(), AccountRole.ADMIN, AccountRole.USER);
makeUser(appProperties.getUserUsername(), appProperties.getUserPassword(), AccountRole.USER);
}

private void makeUser(String email, String password, AccountRole... roles) throws Exception {
accountService.saveAccount(
Account.builder()
.email(email).password(password).roles(Set.of(roles))
.build()
);
}
};
}
}

설명

  • AppProperties를 주입받아서 프로퍼티파일에 들어왔던 정보를 가져온다
  • 계정을 만들어 저장하는 부분의 중복 코드가 없도록 따로 분리하였다
  • role부분은 가변인자로 하여 계속적으로 추가 될 수 있도록 하였다

나머지 테스트에서도 해당 AppProperties를 주입받아서 사용하고
생성부분을 @Before쪽의 deleteAll수행후에 하도록 하면 모든 테스트가 성공한다

이벤트 api 점검

이벤트 API점검은 postman,fiddler, restlet clinet 등의 툴로 확인하면서
점검 할 수 있다

토큰 가져오기

  • URL : GET http:localhost:8080/oauth/token HTTP/1.1
  • Authorization : Basic bXlBcHA6cGFzcw==(Clientid와 secret의 base64 인코딩)
  • Content-Type : application/x-www-form-urlencoded(파라미터를 폼으로)
  • 파라미터 : 쿼리스트링으로 해도 되고 body에 실어보내도 된다
    username,password, grant_type
  • 결과로 access_token을 얻을 수 있다

GET테스트(실패한다)

  • URL : GET http://localhost:8080/api/events HTTP/1.1
  • Host: localhost:8080
  • Authorization: Bearer bb712704-f302-4c0b-8f89-bbc5b2487a0c
    (아까 얻은 access token)
  • 결과 : 실패한다. ResourceServerConfig에서 anonymous로 세팅 하였기 떄문
    서버로그로 보면 유저가 anonymous아니여서 denied 하였다고 한다
  • 해당부분을 permitAll()처리하면 인증후에도 확인 된다

Create 테스트

  • POST로 바디에 json데이타를 품고 Barer 토큰과 같이 Send
  • 결과에서는 링크들이 추가 되어 있음을 확인 할 수 있다

이벤트 조회시 링크 제공?
이벤트 조회시 해당 이벤트에 대한 수정링크는 로그인 했을때만 제공하도록 해야한다
그럴려면 컨트롤러에서 사용자 정보를 조회할 필요가 있다

스프링 시큐리티 현재 사용자

SecurityContext

  • 자바 ThreadLocal 기반 구현으로 인증 정보를 담고 있음
  • 현재 사용자 정보를 꺼내는 방법
    • Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    • 컨트롤러에 추가하고 다음 줄에 breakpoint 걸고 테스트등을 돌려보면 확인 가능
    • 테스트 중 인증 정보가 없는 경우에는 ROLE_anonymous로 보일 것이고
      인증 정보가 있는 경우에는 해당 인증 정보를 디버거에서 확인 가능 하다
    • authentication.getPrincipal()을 하면 Object가 반환되는데 이는 UserDetail을
      구현한 스프링 시큐리티의User로 변환이 가능하다
    • User로 가져온 정보의 username인 “user@email.com“으로 DB를 조회하면 된다
    • 그러나 우리가 써먹기 위해서는 User가 아닌 account로 가져오는게 목적
    • 스프링 mvc 핸들러 파라미터에 @AuthenticationPrincipal애노테이션을 사용하면
      getPrincipal()로 리턴 받을 수 있는 객체를 바로 주입 받을 수 있다
      @AuthenticationPrincipal을 이용해서 getPrincipal()리턴 객체를 바로 주입
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @GetMapping
      public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler,
      @AuthenticationPrincipal User user) {


      Page<Event> page = eventRepository.findAll(pageable);
      // PagedResources<Resource<Event>> pagedResources = assembler.toResource(page);
      PagedResources<EventResource> pagedResources = assembler.toResource(page, e -> new EventResource(e));
      //profile
      pagedResources.add(new Link("/docs/index.html#resources-events-list").withRel("profile"));
      //유저인경우 create-event 추가
      if(user!=null) {
      pagedResources.add(linkTo(EventController.class).withRel("create-event"));
      }

      return ResponseEntity.ok(pagedResources);
      }
  • 위의 queryEvents()경우에는 조회는 무조건 하고 단순히 해당 user 의 유무(null유무)
    를 판단하여 create 링크만 추가한다
  • 따라서 인증정보 참조가 필요 없기 떄문에 위처럼 springSecurity의 User로 받아도 무방
  • 하지만 createEvent()의 경우에는?
    • 현재 사용자의 정보를 event에 반영한 후 save()해야함
    • event 엔티티의 경우에는 Account manager로 저장하므로 User가 아닌 Account로
      가져와야함
  • 위처럼 @AuthenticationPrincipal을 이용해서 받는 객체는 UserDetailsService의
    구현체인 AccountService의 loadUserByUsername에서 리턴하는 UserDetails이다
    (현재 Override한 loadUserByUsername에서 사용하는 스프링 시큐리티 User도
    UserDetail의 구현체다)
  • 따라서 User를 Account로 바꿔줄수 있는 아답터 객체가 필요하다
AccountAdapter.java
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
package me.rkaehdaos.springrestapiprojectdemo.account;

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 AccountAdapter extends User {
private Account account;

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

//UserService의 authorities코드를 복사
private static Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles) {
return roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_"+r.name()))
.collect(Collectors.toSet());
}
//getter, 1개라 lombok 안쓰고 직접
public Account getAccount() { return account;}
}

설명

  • 스프링 시큐리티의 User를 상속받은 아답터 클래스
  • UserService인 AccountService에서 미리 구현한 authorities의 코드를 그대로 복사

AccountService의 loadUserByUsername에서는 UserDetail을 반환한다
User는 UserDetail을 구현하고 있고 AccountAdapter는 이 User를 상속 받았기 때문에
loadUserByUsername안에서 이제 AccountAdapter를 반환해도 된다

AccountService.java, User대신 AccountAdapter를 반환
1
2
3
4
5
6
7
@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()));
return new AccountAdapter(account);
}

AccountAdapter를 리턴한다는 이야기는 이제 컨트롤러에서
이 Account AccountAdapter를 받을 수 있다는 이야기다

AccountAdapter를 사용하면 AccountAdapter의 getter를 사용해서 도메인인 현재 어카운트를 꺼낼 수 있다. 이러면 DB를 사용하지 않고도 현재 사용자의 정보에 접근 할 수 있으니 이벤트를 생성할때 현재 Event의 manager를 세팅할 수 있게 된다.

@AuthenticationPrincipal에서 AccountAdapter로 받기
1
2
3
4
5
6
7
8
9
10
11
@GetMapping
public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler,
@AuthenticationPrincipal AccountAdapter currentUser) {
Page<Event> page = eventRepository.findAll(pageable);
PagedResources<EventResource> pagedResources = assembler.toResource(page, e -> new EventResource(e));
pagedResources.add(new Link("/docs/index.html#resources-events-list").withRel("profile"));
if(currentUser!=null) {
pagedResources.add(linkTo(EventController.class).withRel("create-event"));
}
return ResponseEntity.ok(pagedResources);
}

우리가 필요한 것은 Account이므로 직접 받아보자

  • @AuthenticationPrincipal(expression=”account”) Account account
  • SpEL을 사용
  • 이렇게 하면 아까 받았던 AccountAdapter가 가진 객체 정보 중에 “account”라는
    필드값을 꺼내서 주입시켜준다
  • 스프링의 @AuthenticationPrincipal 에노테이션은 메타 에노테이션을 지원하므로
    위의 expression까지 커스텀 애노테이션을 만들 수 있다
@AuthenticationPrincipal의 expression까지 확장한 @CurrentUser
1
2
3
4
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "account")
public @interface CurrentUser {}

이제 @CurrentUser Account account로 받으면 좀더 코드를 줄일 수 있다

지금까지 인증된 사용자 정보를 가져와서 테스트를 하였다
만약 인증되지 않은 사용자로 테스트를 하면 어떻게 될까?
방금까지 성공한 queryEventsWithAuthentication()테스트 대신
인증정보가 없는 queryEvents()테스트를 수행하면 실패하게 된다
잘 동작하지 않는 이유는 anonymous때문이다
리턴 값 Object가 UserDetail로 변환될 수 있는 객체가 아닌
anonymous 인경우에는 사용자 정보를 가져오는 SecurityContextHolder.getContext().getAuthentication().getPrincipal()의
“anonymous”라는 String이 오게 된다

queryEvents()로 확인하기 위해선 지금까지 고친 컨트롤러를 또 손대야하므로
anonymous에서 잘 작동하는 getEvent()에서 확인할 수 있다
컨트롤러 getEvent()에 SecurityContextHolder.getContext().getAuthentication().getPrincipal()
를 삽입해서 디버그 해보면 “anonymousUser”라는 문자열임을 확인할 수 있다

따라서 @CurrentUser에 정의한 “account” expression이 동작할 수가 없는 것이다
account라는 getter를 사용하겠다는 것인데 String에는 getAccount가 없기 떄문이다
따라서 이 경우 까지 대비하여서 코딩하여야 한다
SpEL의 유연함은 이 부분까지 커버할 수 있는 표현식을 쓸 수 있다

anonymousUser일때를 대비한 expression을 사용하는 SpEL의 유연성
1
2
3
4
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {}

이제 EventController에서 account를 설정할 수 있게 되었다
createEvent()

  • event.update()후 repository.save()하기전에 event.setManager(currentUser);
    1
    2
    3
    4
    Event event = modelMapper.map(eventDto,Event.class);
    event.update();
    event.setManager(currentUser); // 현재 유저 정보 추가
    Event resultEvent = this.eventRepository.save(event);
    queryEvents()
  • 앞에서 했던 것. 추가적인 “create-event”링크를 추가하였다
    getEvent()
  • event.getManager()의 사용자가 현재 사용자와 같다면 수정권환이 있는
    “update-event”링크를 추가 한다
    1
    2
    3
    if(event.getManager().equals(currentUser)){
    eventResource.add(linkTo(EventController.class).slash(event.getId()).withRel("update-event"));
    }
    updateEvent()
  • 업데이트를 하기 위해 가져온 event의 manager가 현재 사용자가 아니라면
    인가되지 않았다는 응답을 보낸다
    (source는 39step종료, 뻘젓거리 한거 기록 예정)

API 개선

createEvent를 실행한 결과를 보면 심각한 문제가 있음을 알 수 있다

createEvent의 responseBody부분
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
{
"id":5,
"name":"springname",
"description":"spring rest api",
"beginEnrollmentDateTime":"2019-02-20T11:53:00",
"closeEnrollmentDateTime":"2019-02-21T11:55:00",
"beginEventDateTime":"2019-03-20T11:53:00",
"endEventDateTime":"2019-03-21T11:53:00",
"location":"강남",
"basePrice":100,
"maxPrice":200,
"limitOfEnrollment":100,
"offline":true,
"free":false,
"eventStatus":"DRAFT",
"manager":{
"id":4,
"email":"user@email.com",
"password":"{bcrypt}$2a$10$Yu/yED93Sqc6SpX8kk3CWu4KM1qGO3w.HyCfeKBkeJZ8tGRFr4k9y",
"roles":[
"USER"
]
},
"_links":{
"query-events":{
"href":"http://localhost:8080/api/events"
},
"self":{
"href":"http://localhost:8080/api/events/5"
},
"update-event":{
"href":"http://localhost:8080/api/events/5"
},
"profile":{
"href":"/docs/index.html#resources-events-create"
}
}
}

앞에서 manager를 추가하였더니 manager의 전체 정보가 노출되어버렸다
위의 경우에는 사실 manager 의 id만 나오고 나머지는 노출되지 않아야한다

이를 해결하기위해서 여러 방법이 있을 수 있다
예를 들어 User 정보를 내보낼 DTO를 새로 만들어 사용할 수도 있겠다
여기서는 JsonSerializer를 사용한다
이전에 사용했던 방법과는 조금 다른 방법으로 JsonSerializer를 사용한다
기억이 안날까봐 이전의 방법을 되짚어 보자면

1
2
3
4
5
6
7
8
9
10
11
12
@JsonComponent
public class ExceptionSerializer extends JsonSerializer<Exception> {
@Override
public void serialize(Exception e, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStartArray();
gen.writeStartObject();
gen.writeStringField("exceptionMessage",e.getMessage());
gen.writeStringField("exceptionClass",e.getClass().toString());
gen.writeEndObject();
gen.writeEndArray();
}
}

지난번에 사용했을 때는 @JsonComponent를 붙여 objectMapper에 등록이 되게끔 했지만
이번에는 그러지 않고 사용한다

AccountSerializer.java , id만 내보낸다. @JsonComponent가 없다.
1
2
3
4
5
6
7
8
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();
}
}

만약 JsonComponent로 등록 해버리면 account 리소스를 내보낼때마다 모든 account가 id만 나가게 된다. 따라서 JsonComponent로 등록하지 않고 Event 에서 manager를 사용할 때만 이 Serializer를 사용하도록 한다

도메인 Event.java의 일부분. JsonSerialize(using)부분을 잘 볼 것
1
2
3
@ManyToOne
@JsonSerialize(using = AccountSerializer.class)
private Account manager;

Event를 Serialize할 때는 Account를 구체적으로 알 필요가 없으므로 위처럼 새로 만든
Serializer를 사용하도록 한다. 이렇게 하면 바뀐 응답을 확인할 수 있다

변경된 createEvent의 responseBody부분. manager의 id만 출력된다
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
{
"id":5,
"name":"springname",
"description":"spring rest api",
"beginEnrollmentDateTime":"2019-02-20T11:53:00",
"closeEnrollmentDateTime":"2019-02-21T11:55:00",
"beginEventDateTime":"2019-03-20T11:53:00",
"endEventDateTime":"2019-03-21T11:53:00",
"location":"강남",
"basePrice":100,
"maxPrice":200,
"limitOfEnrollment":100,
"offline":true,
"free":false,
"eventStatus":"DRAFT",
"manager":{
"id":4
},
"_links":{
"query-events":{
"href":"http://localhost:8080/api/events"
},
"self":{
"href":"http://localhost:8080/api/events/5"
},
"update-event":{
"href":"http://localhost:8080/api/events/5"
},
"profile":{
"href":"/docs/index.html#resources-events-create"
}
}
}

Related POST

공유하기