개발자의 서재

Chapter.05-2 : 스프링 시큐리티와 OAuth 2.0 으로 로그인 구현하기(코드 개선 + 테스트) 본문

SpringBootProject/SpringBoot_ Oauth_AWS

Chapter.05-2 : 스프링 시큐리티와 OAuth 2.0 으로 로그인 구현하기(코드 개선 + 테스트)

ironmask431 2022. 2. 18. 22:55

"스프링부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책을 바탕으로 학습목적의 프로젝트를 진행하고 있습니다. 

 

소스 : https://github.com/ironmask431/springboot_aws_01

Chapter 05-2. 스프링 시큐리티와 OAuth 2.0 으로 로그인 구현하기

5.6 어노테이션 기반으로 개선하기 

개선이 필요한 코드에는 어떤것들이 있을까? 대표적으로 같은코드의 반복이다. 

현재는 IndexController.java 에 세션에서 유저정보를 가져오는 아래 코드가 반복적으로 사용된다. 

SessionUser user = (SessionUser) httpSession.getAttribute("user");

위 부분을 메소드 인자값으로 세션을 받을 수 있도록 개선 하겠다. 

* LoginUser @Interface 생성

package com.jojodu.book.springboot.config.auth;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
/*
 * @Target(ElementType.PARAMETER)
 * 이 어노테이션이 생성될 수 있는 위치를 지정합니다.
 * PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있습니다.
 * 이외에도 클래스 선언문에 쓸수있는 TYPE 등이 있습니다.
 *
 *@Retention(RetentionPolicy.RUNTIME)
 * 이 어노테이션 클래스를 어느범위까지 사용하지 설정
 * SOURCES : 컴파일시 사라짐. 사실상 주석
 * CLASS : 컴파일까지 유지, 런타임시 사라짐. 디폴트
 * RUNTIME : 런타임에도 유지. 런타임종료시 사라짐.
 *
 * @interface
 * 이 파일을 어노테이션 클래스로 지정합니다.
 */

* LoginUserArgumentResolver.java 생성  (HandlerMethodArgumentResolver 구현 )

package com.jojodu.book.springboot.config.auth;

/**
 * HandlerMethodArgumentResolver 기능 지원 :
 * 조건에 맞는 메소드가 있다면 HandlerMethodArgumentResolver 구현체가 지정한 값을
 * 해당 메소드의 파라미터로 넘길 수 있습니다.
 */

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
    private final HttpSession httpSession;

    //컨트롤러 메서드가 특정 파라미터를 지원하는지 판단합니다.
    @Override
    public boolean supportsParameter(MethodParameter parameter){
        boolean isLoginUserAnnotation =
                parameter.getParameterAnnotation(LoginUser.class) != null;
                //메소드의 파라미터에 @LoginUser 어노테이션이 붙어 있는지 여부
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        //파라미터 클래스타입이 SessioUser 인지 여부
        return isLoginUserAnnotation && isUserClass;
    }

    //파라미터에 전달할 객체를 생성합니다.
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer
    , NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception{
        return httpSession.getAttribute("user");
        //세션의 user 정보를 리턴합니다.
    }
}

위 생성한 LoginUserArgumentResolver 가 스프링에서 인식 될 수 있도록 

* WebConfig.java 생성 

package com.jojodu.book.springboot.config.auth;

/**
 * LoginUserArgumentResolver 가 스프링에서 인식될 수 있도록 함.
 */
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    /**
     * HandlerMethodArgumentResolver 는 항상 WebMvcConfigurer 의
     * addArgumentResolvers()를 통해 추가해야 합니다. 다른 HandlerMethodArgumentResolver
     * 추가가 필요하다면 같은 방식으로 추가해줍니다.
     */
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers){
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

* IndexController.java 에서 세션에서 유저정보 읽어오는 부분 수정

@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user){
    model.addAttribute("posts", postsService.findAllDesc());
    //SessionUser user = (SessionUser) httpSession.getAttribute("user");
    //@LoginUser 를 사용하기 때문에 위 코드는 주석처리함.
    //어느 컨트롤러 든지 @LoginUser 를 사용하면 세션유저 정보를 가져올 수 있습니다.
    if(user != null){
        model.addAttribute("userName",user.getName());
    }
    return "index";
}

5.7 세션 저장소로 데이터베이스 사용하기

현재는 세션정보가 내장톰캣was의 메모리에 저장되고 있으므로,   배포시 was를 재기동 시키면 

초기화 되는 문제가 있다.  그리고 2대이상의 서버에서 서비스 하고 있다면 톰캣마다 세션동기화설정을

별도로 해줘야한다. 이에 대한 대안은 주로 아래 3가지를 사용한다. 

 

1. 톰캣세션 사용 

- 2대 이상의 was가 구동되는 환경에서 세션 동기화 설정을 별도로 한다. 

 

2. DB를 세션저장소로 사용한다. 

- 여러 was간의 공용세션을 사용할 수 있는 가장 쉬운 방법

- 많은 설정은 필요없지만 로그인마다 DB 조회를 하므로 성능상 이슈가 생길 수도 있음

- 로그인 요청이 많이없는 백오피스, 사내시스템에 적합함. 

 

3. Redis, Memcached 와 같은 메모리 DB를 세션저장소로 사용한다. 

- B2C 서비스에서 가장 많이 사용하는 방식

- 실제 서비스로 사용하기 위해서는 외부메모리 서버가 따로 필요하다. 

 

여기서는 2번 방법을 선택해서 진행해보겠다.  선택이유는 설정이 간단하고 비용 절감을 위해서이다.

(레디스와 같은 서비스(엘라스틱 캐시)는 별도록 사용료를 지불해야함.)

 

* build-gradle에 spring-session-jdbc 등록

//session-jdbc추가
implementation('org.springframework.session:spring-session-jdbc')

* application.properties 에 추가 

# spring-security 로그인 세션 저장소를 jdbc로 하도록 설정
spring.session.store-type=jdbc

 

추가후 애플리케이션 실행 후 h2-console에 접속하면

세션을 위한 테이블이 2개 생성된 것을 확인가능.  JPA로 인해 세션테이블이 자동생성 되었다. 

로그인을 하면 세션이 테이블에 저장된다. 

로그인 후 

물론 현재는 H2 DB기반이기 때문에 톰캣재시작 시 위 세션정보도 날아간다.

이후 AWS의 데이터베이스 서비스인 RDS 를 사용시에는 서버가 재시작되어도 세션이 유지된다. 

5.6 기존 테스트에 시큐리티 적용하기

기존에는 api를 바로 호출할 수 있어 테스트 코드 역시 api를 호출하도록 구성했다.

하지만 시큐리티 옵션이 활성화 되면 인증된 사용자만 api를 호출 할 수 있고, api 테스트 코드들은 

권한이 없으므로,  테스트 코드마다 인증된 사용자가 호출한 것처럼 작동되도록 수정함. 

 

* 우측상단 gradle 탭의 Task > verification > test 더블클릭하면 전체테스트 실행됨. 

전체 테스트 결과&amp;amp;nbsp;

여러 테스트가 실패하는 것을 확인 할 수 있다. 

 

* Posts 등록, 수정 테스트 실패원인-1

* 오류 302 FOUND 

302(리다이렉션 응답) - Posts 등록 api는 "USER" 권한이 있는 사용자만 접근가능하도록 했기때문에

스프링시큐리티에서 인증되지 않은 사용자의 요청은 이동시켰기 때문.

테스트 api 요청은 임의로 인증된 사용자를 추가하여야 한다. 

 

* build.gradle 에 스프링시큐리티 테스트를 위한 라이브러리 추가.

//스프링시큐리티 테스트를 위한 여러도구 지원
testImplementation('org.springframework.security:spring-security-test')

* PostsApiContollerTest.java 에  유저권한 부여 추가

//게시글 저장 테스트
@Test
@WithMockUser(roles = "USER") //테스트시 권한 "UESR" 를 부여하여 api요청할수 있게함.
public void Posts_등록된다() throws Exception { ... }

//게시글 수정 테스트
@Test
@WithMockUser(roles = "USER")
public void Posts_수정된다() throws Exception { ... }

WithMockUser는 MockMvc에서만 동작하기 때문에 @SpringBootTest 에서 MockMvc를 사용하는 

방식으로 코드 변경이 필요함. - 추가

@Autowired
private WebApplicationContext context;

private MockMvc mvc;

@Before //테스트 시작전 실행
public void setUp(){
    mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity()).build();
}

코드수정, 추가 (기존 ResponseEntity 로 테스트하던 부분은 주석처리하고 mvc로 테스트하는 코드 추가 )

public void Posts_등록된다() throws Exception { 
....
//when
//기존
//ResponseEntity 를 이용해서 해당url 에 requestDto로 게시글저장 실행
//ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

//@SpringBootTest 에서 MockMvc 사용할 수 있는 방법으로 수정함.
//생성된 MockMvc를 통해 api를 테스트합니다.
//본문body 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환합니다.
mvc.perform(post(url)
        .contentType(MediaType.APPLICATION_JSON_UTF8)
        .content(new ObjectMapper().writeValueAsString(requestDto)))
        .andExpect(status().isOk());

//then
//기존
//assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); //요청응답 status 정상확인
//assertThat(responseEntity.getBody()).isGreaterThan(0L); //저장후 키값이 정상리턴되었는지 확인
...
}

//게시글 수정 테스트
public void Posts_수정된다() throws Exception {
	.....
    //게시글 수정 요청을 보낼 requestDto를 담은 requestEntity 생성
    //HttpEntity<PostUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

    //when
    //ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
    
    mvc.perform(put(url)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content(new ObjectMapper().writeValueAsString(requestDto)))
                    .andExpect(status().isOk());

    //then
    //assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
    //assertThat(responseEntity.getBody()).isGreaterThan(0L);
	....
}

수정 후 테스트 성공 확인

 

* hello., helloDto 리턴 테스트 실패원인-2

* No qualifying bean of type "..CustomOAuth2UserService" 관련 오류

HelloControllerTest 는 1번 오류와 다른점이 있다. 

@WebMvcTest를 사용한다는 점이다. @WebMvcTest는 @Controller 만 스캔하고, 

@Repository, @Service, @Component는 스캔대상이 아니므로, @Service 로 등록된 CustomOAuth2UserService

는 스캔하지않아 에러가 발생함. CustomOAuth2UserService를 조회하는 SecurityConfig를 스캔대상에서 제거함. 

* 이 방법은 언제 삭제될지 모르니 사용하지 않는걸 추천

@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
        @ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
}
// 스캔대상에서 SecurityConfig 제거 
//(@WebMvcTest 는 @Service 는 스캔대상이 아니라서 CustomOAuth2UserService 는 스캔못함.)

인증된 사용자 정보 추가 

@Test
@WithMockUser(roles = "USER")
public void hello가_리턴된다() throws Exception { ... } 

@Test
@WithMockUser(roles = "USER")
public void helloDto가_리턴된다() throws Exception { ... }

위 까지 적용 후 테스트 시 아래 에러 발생

@EnableJpaAuditing 관련 에러인데, JPA를 사용하기 위해서는 최소 하나의

@Entity 클래스가 필요함. @WebMvcTest 다 보니 Entity 클래스가 없음.

@EnableJpaAuditing 가 @SpringBootApplicaton 쪽에 명시되어 있으니 @WebMvcTest에서도 

스캔하게됨. 그래서 @EnableJpaAuditing을 Application.java에서 다른곳으로 옮기는게 필요함. 

 

* Applicaton.java 에서 @EnableJpaAuditing 제거 

//@EnableJpaAuditing //JPA Auditing 활성화 - 이설정은 이후 JpaConfig.java 로 옮김.
@SpringBootApplication
public class Application {...}

* JpaConfig.java 생성 + @EnableJpaAuditing 적용

package com.jojodu.book.springboot.config;

@Configuration
@EnableJpaAuditing // JPA Auditing 활성화
public class JpaConfig { }

이후 전체테스트실행 및 정상 확인. 

Comments