BE/6기 코테이토 - Spring Study

3회 - Ch.5(2) 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

유쓰응 2023. 2. 8. 18:46

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

 

개선이 필요한 나쁜 코드란?

같은 코드가 반복되는 부분. 수정시 모두 수정을 해야하기 때문! -> 수정이 반영되지 않을 수 있음.

 

앞에서 만든 로그인 관련 코드에서 개선할 부분이 있을까?

IndexController에서 세션값을 가져오는 부분이 그러하다. 

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

해당 기능은 index 메소드가 아닌 다른 메소드에서도 충분히 활용될 수 있으므로 반복되지 않게 따로 분리해주는 것이 좋다.

 

@LoginUser 어노테이션 추가.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

 

@Target(ElementTYPE.PARAMETER)

- 해당 어노테이션(@LoginUser)이 생성될 수 있는 위치를 지정함. 즉, @LoginUser는 메소드의 파라미터로 선언된 객체에만 적용이 가능함.

(파라미터에만 해당 어노테이션을 생성할 수 있다.)

 

@interface : 해당 파일을 어노테이션 클래스로 지정한다는 뜻.

 

 

LoginUserArgumentResolver ( 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;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        return isLoginUserAnnotation && isUserClass;

    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }


}
더보기

@Component : 프로그래밍에서 재사용할 수 있는 각각의 독립된 모듈

하나의 레고를 조립할때 나오는 여러 블록들을 이야기함.

- 부품이 고장났을때 다른 부품으로 교체하면 된다.라는 개념에서 차용됨.

-이렇게 작성된 코드는 프레임워크에 상관없이 상호 운용이 가능하다.

@Override

 해당 클래스가 부모 클래스의 ( interface의 ) 메소드를 사용하였다고 명시적으로 선언하는 것.

supportParameter() : 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단함.

- isLoginUserAnnotation : 해당 메소드가 @LoginUser.class 이면 True

- isUserClass : 파라미터 클래스타입이 SessionUser.class인 경우 True

 

resolveArgument() : 파라미터에 전달할 객체를 생성함.

...?

 

이렇게 @LoginUser를 사용하기 위한 환경을 구축했다.

이제 이 LoginUserArgumentResolver가 스프링에서 인식되도록 WebMvcConfigurer를 추가하자.

 

WebConfig

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers){
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

 

이렇게 WebConfigurer의 addArgumentResolvers()를 통해 HandlerMethodArgumentResolver가 추가된다.

 

@LoginUser를 통해 이제 User를 받아보자.

@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user){
    model.addAttribute("posts", postsService.findAllDesc());
	//SessionUser user = httpSession.getAttribute("user");
    if(user!=null){
        model.addAttribute("userName",user.getName());
    }
    return "index";
}

 

 httpSession.getAttribute("user")로 user를 가져오던 것을 @LoginUser 어노테이션 파라미터를 통해 가져왔다.

 

 

 5.5 세션 저장소로 DB 사용하기.

 현재 만든 어플리케이션은 서버를 한번 중지하고 재실행하면 기존의 데이터가 다 삭제된다. 로그인도 다시해야하고, 게시글도 삭제된다. 왜그럴까?

Why?

- 세션이 '내장 톰캣 메모리'에 저장되기 때문이다. 기본적으로 WAS의 메모리에 저장되니 실행 시 항상 초기화가 된다.

 

또 다른 문제로는 2대 이상 서버에서 서비스를 한다면 톰캣마다 세션 동기화 설정을 진행해야한다. 그로 인해 현업에선 세션 저장소로 3가지 중 하나를 선택한다.

 

(1) 톰캣 세션을 사용한다.

- 별다른 설정을 하지 않을때의 기본 값 (우리가 한 것)

- 톰캣(WAS)에 저장되기에 동기화 문제해결을 위한 별도의 설정이 필요함.

 

 

(2) MySQL과 같은 DB를 세션 저장소로 사용한다.

- WAS간의 공용 세션을 사용할 수 있음.

- 별다른 설정이 필요 없지만 요청마다 DB에 I/O(입출력) 요청이 발생 -> 요청이 많아질수록 성능 문제 야기 가능.

 

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

- B2C (Business To Client) 서비스에서 가장 많이 사용함.

 

우리는 2번 방법으로 진행하겠다. ( 설정이 쉽고 , 사용자가 적으니 성능 우려 적음 )

build.gradle에 다음 코드를 추가한다

implementation 'org.springframework.session:spring-session-jdbc'

또한 application.properties에도 다음 코드를 추가한다.

spring.session.store-type=jdbc

위 두가지 설정을 통해 세션 저장소를 jdbc로 선택하도록 코드를 추가했다. 이후 h2-console을 보면 다음과 같다.

spring_session, spring_session_attribute 두 개의 테이블이 생겼다.

 

그러나, 아직도, 어플리케이션을 재시작하면 초기화가 된다. H2 기반으로 스프링이 재시작될때 H2도 재시작 되기 때문이다. AWS 배포시 RDS를 사용할 것이니 이 때부턴 세션이 풀리지 않는다.

 

5.6 네이버 로그인

네이버 로그인은 구글서비스와 다르게 기본 설정을 제공하지 않기 때문에 코드를 직접 쳐야한다고 했다. 진짜..?

https://developers.naver.com/apps/#/wizard/register

 

애플리케이션 - NAVER Developers

 

developers.naver.com

네이버 오픈 API로 이동해 서비스를 등록하자 우선.

 

환경을 웹 어플리케이션으로 설정하고 승인된 redirection url( = callback url)에 http://localhost:8080/login/oauth2/code/naver를 추가한다. 여기서 'naver'를 제외하곤 구글서비스와 동일하다.

application-oauth.properties에 네이버 관련 코드를 추가한다.

#registration
spring.security.oauth2.client.registration.naver.client-id= 아이디
spring.security.oauth2.client.registration.naver.client-secret=시크릿
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization_grant_type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

#provider
spring.security.oauth2.client.provider.naver.authorization_uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token_uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user_name_attribute=response

*user_name_attribute=response : 네이버 회원 조회시 반환형이 JSON 객체이기 때문에 기준이 되는 user_name의 이름을 네이버는 response로 해야한다.

 

더보기

*오류 : uri -> rui로 쳐서 Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration$EnableWebMvcConfiguration': Unsatisfied dependency expressed through method 'setConfigurers' parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration': Unsatisfied dependency expressed through method 'setClientRegistrationRepository' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'clientRegistrationRepository' defined in class path resource [org/springframework/boot/autoconfigure/security/oauth2/client/servlet/OAuth2ClientRegistrationRepositoryConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository]: Factory method 'clientRegistrationRepository' threw exception; nested exception is java.lang.IllegalArgumentException: authorizationUri cannot be empty 이런 오류가 나왔는데 검색도 안됐고 이해도 어려웠음. 오류 잘 찾아보자.

여기까지 naver login 등록이었다.

 

 

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

 

기존 테스트에 시큐리티 적용으로 문제가 되는 부분을 해결해보자.

어디가 문제가 될까?

기존 : API를 바로 호출할 수 있어 테스트 코드 또한 바로 API를 호출하게 구성함.

현재 : 시큐리티 옵션 추가로 인증된 사용자만 API를 호출할 수 있음.

 

즉, 테스트 코드에게 인증된 사용자가 호출한 것처럼 작동하도록 수정해주자.

 

우선, 현재 전체 테스트를 돌려보면 다음과 같은 메시지가 나온다.

No qualifying bean of type 'com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

CustomOAuth2UserService를 생성시 필요한 소셜로그인 설정값들이 없다는 뜻인데 application-oauth.properties가 있는데 왜 없다고 할까?

src/main이 아닌 src/test 환경의 차이 때문인데 application.properties는 src/test에서 자동으로 가져오지만 그 이상은 안 가져오기때문. 즉 ,테스트환경을 위한 application.properties를 만들어주자!

spring.jpa.show_sql = true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.profiles.include=oauth
spring.session.store-type=jdbc

# Test OAuth
spring.security.oauth2.client.registration.google.client-id = test
spring.security.oauth2.client.registration.google..client-secret = test
spring.security.oauth2.client.registration.google.scope=profile,email

구글 연동을 실제로 하는 것은 아니기에 가짜 설정 값 ( = id, secret )을 설정하자.

 

이렇게 수정하고 나면 두번째 문제가 발생한다.

 

2. 302 Status Code

정상 응답은 200인데 302가 등장했다.

302 : 리다이렉션 응답

- 인증되지 않은 사용자의 요청은 이동시키기 때문임.

즉, 임의로 인증된 사용자를 추가하면 api만 테스트를 할 수 있음.

 

@Test
@WithMockUser(roles = "USER")
public void Posts_수정된다() throws Exception{
    //given
    Posts savedPosts = postsRepository.save(Posts.builder()
            .title("title")
            .content("content")
            .author("author")
            .build());
    Long updateId = savedPosts.getId();
    String expectedTitle = "title2";
    String expectedContent = "content2";

    PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
            .title(expectedTitle)
            .content(expectedContent)
            .build();

    String url = "http://localhost:" + port + "/api/v1/posts/"+updateId;
    HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

    //when
    ResponseEntity<Long> responseEntity = restTemplate.exchange(url,HttpMethod.PUT,requestEntity,Long.class);

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

    List<Posts>all = postsRepository.findAll();
    assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
    assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}

@WithMockUser(roles = "USER") : 임의의 유저를 사용하자. roles를 활용해 권한을 추가하자.

 

그러나, 위 어노테이션은 MockMvc에서만 작동하기 때문에 @SpringBootTest에선 작동하지 않는다. 따라서, PostsApiContollerTest에서 MockMvc 를 사용하려고 코드를 수정해야한다.

 


@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception{
        postsRepository.deleteAll();
    }

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup(){
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }
    @Test
    @WithMockUser(roles = "USER")
    public void Posts_등록된다() throws Exception{
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        //ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url,requestDto,Long.class);

        mvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .contentType(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());



        //then


        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    @WithMockUser(roles = "USER")
    public void Posts_수정된다() throws Exception{
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());
        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/"+updateId;
        HttpEntity<PostsUpdateRequestDto> 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
        

        List<Posts>all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

새로운 postsApiControllerTest

 

@Before : 매 테스트 시작전에 수행되는 메소드 -> 여기선 MockMvc 생성 역할

 

mvc.perform

 

 

이제 HelloController의 두개 테스트만 실패한다.