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

LeeKyoungChang·2022년 5월 20일
0
post-thumbnail

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 를 공부하고 정리한 내용입니다.

 

📚 1. 네이버 로그인

📖 A. 네이버 API 등록

네이버 오픈 API

 

네이버1

  • 회원 이름, 이메일, 프로필 사진은 필수이다.

 

네이버2

  • 서비스 URL은 필수이다.
  • Callback URL은 구글에서 등록한 리디렉션 URL과 같은 역할을 한다.

여기서는 http://localhost:8080/login/oauth2/code/naver

등록을 완료하면 ClientIDClientSecret이 생성된다.

 

✔ application-oauth.yml
Client IDClient Secret 정보를 등록해야 한다. 네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 CommonOAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 한다.

spring:  
  security:  
    oauth2:  
      client:  
        registration:  
			~
  
          naver:  
            clientId: xxxx
            clientSecret: xxxx 
            redirect-uri: {baseUrl}/{action}/oauth2/code/{registrationId} # http://localhost:8080/login/oauth2/code/naver  
            authorization-grant-type: authorization_code  
            scope: name, email, profile_image  
            client-name: Naver  
  
        provider:  
          naver:  
            authorization_uri: https://nid.naver.com/oauth2.0/authorize  
            token_uri: https://nid.naver.com/oauth2.0/token  
            user-info-uri: https://openapi.naver.com/v1/nid/me  
            user_name_attribute: response
  • user_name_attribute: response
    • 기준이 되는 user_name의 이름을 네이버에서는 response로 해야 한다.
    • 네이버 회원 조회 시 반환되는 JSON 형태 때문이다.
      • 스프링 시큐리티에서는 하위 필드를 명시할 수 없는데, 네이버 응답값 최상위 필드는 resultCode, message, response이다.
      • 이러한 이유로 스프링 시큐리티에서 인식 가능한 필드는 3개 중에 골라야 하며, 본문에서 담고 있는 responseuser_name으로 지정하고 이후 자바 코드로 response의 id를 user_name으로 지정할 것이다.
  • redirect-uri: baseUrl
    • 네이버 API에 등록한 Callback URL을 입력한다.

 

📖 B. 스프링 시큐리티 설정 등록

이미 구글 로그인을 등록하면서 대부분 코드가 확장성 있게 작성되었다보니, 네이버는 쉽게 등록 가능하다.

 

OAuthAttributes

네이버인지 판단하는 코드와 네이버 생성자만 추가해주면 된다.

public class OAuthAttributes {
	
    ...
    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        if ("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);
    }

	...
    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
	...
}

 

index.mustache

네이버 로그인 버튼을 추가한다.

~
{{^userName}}  
    <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>  
    <a href="/oauth2/authorization/naver" class="btn btn-success active" role="button">Naver Login</a>  
{{/userName}}
~
  • /oauth2/authorization/naver
    • 네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동으로 등록된다.
    • /oauth2/authorization/까지는 고정이고 마지막 Path만 각 소셜 로그인 코드를 사용하면 된다.
    • 여기서는 naver가 마지막 Path가 된다.

 

실행 결과
로그인 결과

  • 네이버 버튼이 활성화된 것을 볼 수 있다.

 

로그인 결과1

  • 네이버 로그인 버튼을 누르면 동의 화면이 등장한다.
  • 첫 네이버으로 로그인할 시, APP 이름나오며 회원 이름, 이메일, 프로필 사진 동의 관련 화면이 나온다. (사진 캡쳐를 하지 못했다.)
  • 이후부터는 네이버 로그인 버튼 클릭시 자동 로그인이 된다.

 

로그인 성공

  • 로그인 성공하였다.

 

📚 2. 기존 테스트에 시큐리티 적용하기

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

기존에는 바로 API를 호출할 수 있어서 테스트 코드 역시 바로 API를 호출하도록 구성했다. 하지만 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있다. 기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정해야 한다.

문제점을 하나씩 살펴보면서 해결해보자.

 

전체 테스트 실행

스크린샷 2022-05-23 오후 5 12 08

 

📖 A. CustomOAuth2UserSerivce를 찾을 수 없다.

첫 번째 실패 테스트인 hello가_리턴된다의 메시지를 보면 다음과 같은 에러를 발견할 수 있다.

스크린샷 2022-05-23 오후 4 31 51
No qualifying bean of type 'springbootawsbook.springawsbook.config.auth.CustomOAuth2UserService' 

CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없기 때문에 발생한다.

application-oauth.yml에 분명히 설정값들을 추가했는데 이러한 이유가 발생한 이유는 src/main과 src/test은 본인만의 환경 구성을 가지기 때문이다. test가 자동으로 가져오는 옵션의 범위는 application.yml까지로, application-oauth.yml는 test에 파일이 없기 때문에 가져오는 파일은 아니라서 에러가 발생한 것이다.

 

✔ 해결 방법
이 문제를 해결하기 위해서는 테스트 환경을 위한 application.yml을 만들어보자!
실제로 구글 연동까지 진행할 것은 아니므로 가짜 설정값을 등록한다.

src/test/resources/application.yml

spring:  
  jpa:  
    show-sql: true  
  
  h2:  
    console:  
      enabled: true  
  
  session:  
    store-type: jdbc  
  
  # Test OAuth  
  security:  
    oauth2:  
      client:  
        registration:  
          google:  
            clientId: google클라이언트ID
            clientSecret: google클라이언트비밀번호
            scope: profile,email

 

다시 그레이들로 테스트를 수행해 보면 다음과 같이 7개의 실패 테스트가 4개로 줄어들었다.

스크린샷 2022-05-23 오후 5 13 27

 

📖 B. 302 Status Code

두 번째 실패 테스트인 Posts_등록()을 보면 응답의 결과로 200을 기대했는데 302(리다이렉션 응답)이 와서 실패한 것을 알 수 있다. 이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문이다.
그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있도록 수정해보자!

 

✔️ 해결 방법

스프링 시큐리티에서 공식적으로 방법을 지원하고 있다.

스프링 시큐리티 테스트를 위한 여러 도구를 지원하는 spring-security-testbuild.gradle에 추가한다.

testImplementation 'org.springframework.security:spring-security-test:5.6.2'

PostApiControllerTest의 2개의 테스트 메소드에 임의 사용자 인증을 추가해보자.

@Test
@WithMockUser(roles = "USER")
public void Post_등록() throws Exception {}

~

@Test
@WithMockUser(roles = "USER")
public void Post_수정() throws Exception {}
  • @WithMockUser(roles="USER")
    • 인증된 모의(가짜) 사용자를 만들어서 사용한다.
    • roles에 권한을 추가할 수 있다.
    • 이 어노테이션으로 인해 ROLE_UER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가지게 된다.

이렇게 해도 @WithMockUserMockMvc에서만 작동하기 때문에 아직 테스트가 작동하지 않는다.
현재 PostApiControllerTest@SpringBottTest로만 되어있으며 MockMvc를 전혀 사용하지 않는다. 그래서 @SrpingBootTest에서 MockMvc를 사용하도록 코드를 다음과 같이 변경해보자.

public class PostApiControllerTest {
	...
    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @BeforeEach
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }
    
    ...
    
    @Test
    @WithMockUser(roles = "USER")
    public void Post_등록() throws Exception {
		...
        
        //when
        mvc.perform(post(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(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
   	
    @Test
    @WithMockUser(roles = "USER")
    public void Post_수정() throws Exception {
		...
        
        //when
        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);
    }
}
  • @BeforeEach
    • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성한다.
  • mvc.perform
    • 생성된 MockMvc를 통해 API를 테스트 한다.
    • 본문 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환한다.

 

전체 테스트를 다시 수행할시, Posts 테스트도 정상적으로 수행되었다.

스크린샷 2022-05-23 오후 5 25 40

 

📖 C. @WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음

제일 앞에서 발생한 “Hello가 리턴된다” 테스트를 확인해보자!
첫 번째로 해결한 것과 동일한 메시지인

No qualifying bean of type ‘com.
jojoldu.book.springboot.config.auth.CustomOAuth2UserService’

이다.

이 문제가 왜 발생한걸까?
HelloControllerTest는 1번과 다르게 @WebMvcTest를 사용한다.
1번을 통해 스프링 시큐리티 설정은 잘 작동했지만, @WebMvcTestCustomOAuth2UserService를 스캔하지 않기 때문이다.

@WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer
를 비롯한 @ControllerAdvice, @Controller를 읽지만 @Repository, @Service, @Component 는 스캔 대상이 아니다.

CustomOAuth2UserService는 읽을 수가 없어서 이와 같은 에러가 발생한 것이다.
이 문제를 해결하기 위해 스캔 대상에서 SecurityConfig를 제거하자!

HelloControllerTest

@WebMvcTest(controllers = HelloController.class,  
        excludeFilters = {  
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)  
        })  
class HelloControllerTest {}

@WithMockUser를 사용해서 가짜로 인증된 사용자를 생성하자

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

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

테스트를 돌려보면 다음과 같은 추가 에러가 발생한다.

java.lang.IllegalArgumentException: At least one JPA metamodel must be present!

@EnableJpaAuditing 때문에 발생한다. @EnableJpaAuditing을 사용하기 위해서는 최소 하나의 @Entity 클래스가 필요한데 @WebMvcTest이다 보니 당연히 없다.
@EnableJpaAuditing@SpringBootApplication와 함께 있다보니 @WebMvcTest에서도 스캔하게 되었다.
그래서 @EnableJpaAuditing과 @SpringBootApplucation 둘을 분리해보자.

Application.java

//@EnableJpaAuditing 제거
@SpringBootApplication  
public class SpringawsbookApplication {...}

config/JpaConfig.java

@Configuration  
@EnableJpaAuditing    //JPA Auditing 활성화  
public class JpaConfig {  
}
  • @WebMvcTest는 일반적인 @Configuration을 스캔하지 않는다.

 

이제 전체 테스트를 수행하면 모든 테스트가 통과한다.

스크린샷 2022-05-23 오후 5 47 28

 

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글