[Spring security + JWT] #4 리팩토링

devwuu·2023년 8월 9일

security

목록 보기
4/6

이 게시글은 강의를 듣고 사이드 프로젝트에 적용한 내용이 주를 이루고 있습니다
수강 강의 : (인프런) 스프링부트 시큐리티 & JWT 강의
🏠 Basic Exam : https://github.com/devwuu/spring-security-exam
🏠 사이드 프로젝트 : https://github.com/devwuu/todaktodak


1. 리팩토링이 필요한 부분

spring security 적용은 어느정도 마무리가 됐으니 리팩토링을 할 순서였다. 당장 수정이 필요한 부분은 크게 세 군데였다.
(1) 컨트롤러 로직. Client에서 파라미터로 받았던 동물병원 id를 UserDetail에서 가져와서 사용하는 방식으로 로직을 수정해야 한다. (2) Test code. security가 적용되면서 권한 문제로 테스트가 제대로 작동하지 않았다. 따라서 테스트 코드 수정을 해줘야 했다. (3) application.yml local에서 사용하는 JWT 설정과 다른 환경에서 사용할 JWT 설정이 달라질 수 밖에 없을 것 같았다. 따라서 환경에 따라 적용될 application 파일을 분리해야 했다.


2. 리팩토링 시작

1. UserDetail 적용

security를 적용해야 겠다고 마음 먹은 가장 큰 이유였다. 컨트롤러에서 파라미터로 받던 동물병원 id를 로그인한 근무자의 동물병원 id로 대체하는 작업이었다. 동물병원 id는 여기저기서 많이 사용되기 때문에 Util 메서드를 구현해서 로직을 수정하기로 했다.

public class SecurityUtil {

    private static final Authentication AUTHENTICATION = SecurityContextHolder.getContext().getAuthentication();

    public static EmployeePrincipal getEmployeePrincipal(){
        if(ObjectUtil.isEmpty(AUTHENTICATION)){
            throw new IllegalStateException("AUTHENTICATION IS EMPTY");
        }
        return (EmployeePrincipal) AUTHENTICATION.getPrincipal();
    }

    public static AdminPrincipal getAdminPrincipal(){
        if(ObjectUtil.isEmpty(AUTHENTICATION)){
            throw new IllegalStateException("AUTHENTICATION IS EMPTY");
        }
        return (AdminPrincipal) AUTHENTICATION.getPrincipal();
    }

    private SecurityUtil(){

    }

}
@PostMapping("save")
    public ResponseEntity<AnimalVO> save(@RequestBody AnimalVO vo){
        if(ObjectUtil.isNotEmpty(vo.id())){
            throw new ValidationException("ID SHOULD BE EMPTY");
        }
        if(ObjectUtil.isEmpty(vo.status())){
            throw new ValidationException("STATUS SHOULD NOT BE EMPTY");
        }
        Long clinicId = SecurityUtil.getEmployeePrincipal().getClinicId();
        AnimalVO saved = animalService.save(vo.clinicId(clinicId));
        return ResponseEntity.created(URI.create("/v1/animal/"+saved.id())).body(saved);
    }

2. Test code 수정

단순 권한 문제도 권한 문제지만 상당수의 로직에 UserDetail이 필요하니 UserDetail을 사용할 수 있도록 Test code를 수정해야 했다. 뿐만 아니라 내 프로젝트에선 API 문서화를 위해 Spring Rest Docs를 사용하고 있었기 때문에 테스트가 정상적으로 완료되고 난 후 문서도 잘 수정되었는지 확인할 필요가 있었다.

MockMvcBuilders에 security를 등록하고 각 테스트에 @WithUserDetails 어노테이션을 사용해 테스트 코드가 정상적으로 작동하도록 수정했다. 테스트 코드가 정상적으로 작동하니 문서 역시 잘 수정되었다.

@SpringBootTest
@AutoConfigureMockMvc
@Import(RestDocsConfiguration.class)
@ExtendWith(RestDocumentationExtension.class)
@Disabled
@Transactional
@ActiveProfiles("local")
public class ControllerTestSupporter {

    protected MockMvc mvc;

    ...

    @BeforeEach
    void setUp(WebApplicationContext context, RestDocumentationContextProvider provider) {

        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                .apply(springSecurity())
                ....
                .build();
    }

}

@WithUserDetails(userDetailsServiceBeanName = "employeeDetailService", value = "test")
class ReservationClientControllerTest extends ControllerTestSupporter {

...

@Test @DisplayName("등록된 예약 리스트에서 보호자 이름으로 검색합니다")
    public void searchAllByGuardianName() throws Exception {
        mvc.perform(get("/v1/client/reservation/search")
                        .param("page", "0")
                        .param("size", "5")
                        .param("guardianName", "ab")
                ).andDo(
                        docs.document(
                                queryParameters(
                                        parameterWithName("guardianName").attributes(field("type", "String")).description("보호지 이름"),
                                        parameterWithName("page").attributes(field("type", "Number")).description("현재 페이지(index)"),
                                        parameterWithName("size").attributes(field("type", "Number")).description("한 번에 보여줄 content 갯수")
                                )
                        )
                )
                .andDo(print())
                .andExpect(status().isOk());
    }
    
...
    
}

3. 환경 분리

간단한 사이드 프로젝트라 일단 profile은 시험삼아 두 가지로 나눠보기로 했다. application-local.ymlapplication-dev.yml파일을 추가하고 application.yml은 공통으로 적용되는 설정을 작성하기로 했다. 기본 profile은 local로 설정했다.

spring:
  config:
    activate:
      on-profile: local
...
app:
  security:
    jwt:
      secret: local
      limit: 1440
      issuer: localhost:8080
...
spring:
  config:
    activate:
      on-profile: dev
app:
  security:
    jwt:
      secret: dev
      limit: 10
      issuer: localhost:8090
spring:
  profiles:
    default: local
  jackson:
    serialization:
      indent-output: true

yml을 읽어오기 위해 JwtProperties를 수정하면서 JwtProvider를 추가로 구현하여 AuthenticationFilerAuthorizationFilter에선 JwtProvider에만 의존하도록 로직을 수정했다. 반복되는 코드도 줄일 겸 나중에 JwtProperties가 변경되어도 JwtProvider 한 군데서만 수정하면 되기 때문에 유지보수하기 좋을 것 같았다.

@ConfigurationProperties 짝궁인 @EnableConfigurationPropertiesAppConfiguration에 추가해줬다. profile을 나누고 나선 ControllerTestSupporter@ActiveProfiles도 추가 해줬다.

@ConfigurationProperties(prefix = "app.security.jwt")
@Getter @Setter
public class JwtProperties {

    private String secret;
    private int limit;
    private String issuer;
    private String prefix = "Bearer ";

    public Instant getExpiredTime(){
        return LocalDateTime.now().plusMinutes(limit).toInstant(ZoneOffset.UTC);
    }

    public Algorithm getSign(){
        return Algorithm.HMAC256(secret);
    }

}
@RequiredArgsConstructor
public class JwtProviders {
    private final JwtProperties properties;

    public String authenticate(UserDetails userDetails) {

        String token = JWT.create()
                .withSubject(properties.getIssuer())
                .withClaim("id", userDetails.getUsername())
                .withExpiresAt(properties.getExpiredTime())
                .sign(properties.getSign());

        return properties.getPrefix() + token;
    }

    public String authorize(String header){

        String id = JWT.require(properties.getSign())
                .build()
                .verify(StringUtil.remove(header, properties.getPrefix()))
                .getClaim("id")
                .asString();

        return id;
    }

    public String removePrefix(String header){
        return StringUtil.remove(header, properties.getPrefix());
    }

    public Boolean isStartWithPrefix(String header){
        return StringUtil.startsWith(header, properties.getPrefix());
    }

}
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class AppConfiguration {

    @PostConstruct
    public void init(){
        TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
    }

}

3. 리팩토링과 고난

아니나 다를까... 리팩토링에서 가장 험난한 산은 항상 환경 설정이다 ^.ㅠ ... gradle로 clean build 를 돌리면 defualt로 설정된 local로만 돌아가고 dev로는 돌아가지 않았다. profile 설정해주는 command는 -Pprofile=dev라고 했는데 전혀 먹히지 않았다. 알고보니 그 profile이 내가 이해한 그 profile이 아니었다... spring boot run에서 사용하는 profile은 SPRING_PROFILES_ACTIVE=dev 라고 한다... 그래서 inteliJ 설정도 바꿔줬다. 환경 설정은 항상 봐도 봐도 모르겠고 새롭다.


4. 출처

profile
일단 한다

2개의 댓글

comment-user-thumbnail
2023년 8월 9일

큰 도움이 되었습니다, 감사합니다.

1개의 답글