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

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

일반적인 프로그래밍에서 같은 코드가 반복되는 부분은 대표적인 코드 개선의 대상이 된다

IndexController에서 세션값을 가져오는 부분은 index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하다면 반복될 가능성이 있다

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

이 부분을 메소드 인자로 세션값을 바로 받아올 수 있도록 변경해보자

config.auth 패키지에 @LoginUser 어노테이션을 생성한다

LoginUser.java

package com.vencott.dev.springboot.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

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

@Target(ElementType.PARAMETER)

  • 이 어노테이션이 생성될 수 있는 위치를 지정
  • PARAMETER로 지정하면 메소드의 파라미터로 선언된 객체에서만 사용할 수 있다
  • 이 이외에도 클래스 선언에 쓸 수 있는 TYPE 등이 있다

@interface

  • 이 파일을 어노테이션 클래스로 지정

같은 위치에 LoginUserArgumentResolver를 생성한다

LoginUserArgumentResolver.java

package com.vencott.dev.springboot.config.auth;

import com.vencott.dev.springboot.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpSession;

@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");
    }
}

implements HandlerMethodArgumentResolver

  • HandlerMethodArgumentResolver 는 조건에 맞는 경우 메소드가 있다면 HandlerMethodArgumentResolver 의 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있다

supportsParameter()

  • 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단
  • 여기서는 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class 인 경우 true를 반환

이렇게 생성된 LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가해야 한다

config 패키지에 WebConfig 클래스를 생성한다

WebConfig.java

package com.vencott.dev.springboot.config;

import com.vencott.dev.springboot.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

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

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

HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers() 를 통해 추가해야 한다

이제 IndexController에서 반복되는 부분을 모두 @LoginUser 로 개선한다

IndexController.java

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

        if (user != null) {
            model.addAttribute("userName", user.getName());
        }

        return "index";
    }

public String index(Model model, @LoginUser SessionUser user)

  • 기존에 httpSession.getAttribute("user") 로 가져오던 세션 정보 값이 @LoginUser 를 사용하는 방식으로 개선
  • 이제 어느 컨트롤러든지 @LoginUser 만 사용하면 세션 정보를 가져올 수 있게 되었다

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

현재 세션 관리의 2가지 문제점

  1. 일반적으로 세션은 WAS의 메모리에 저장되므로 내장 톰캣처럼 애플리케이션 실행 시에만 실행되는 구조에선 항상 초기화되므로 배포할 때마다 초기화된다
  2. 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야한다

현업의 세션 저장소

  1. 톰캣 세션 사용
    • 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정 필요
  2. MySQL과 같은 DB 사용
    • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
    • 로그인 요청마다 DB IO 발생하여 성능 이슈
    • 로그인이 많이 없는 백오피스, 사내 시스템에 사용
  3. Redis, Memcached와 같은 메모리 DB 사용
    • B2C 서비스에서 가장 많이 사용

여기서는 두번째 방식을 선택하여 진행한다

spring-session-jdbc 등록

먼저 build.gradle에 의존성을 등록한다

compile('org.springframework.session:spring-session-jdbc')

application.properties에 세션 저장소를 jdbc로 선택하도록 추가한다

spring.session.store-type=jdbc

테스트

애플리케이션을 시작하고 h2-console을 보면 SPRING_SESSION, SPRING_SESSION_ATTRIBUTES 2개의 테이블이 JPA로 인해 자동 생성된 것을 볼 수 있다

물론 지금도 스프링을 재시작하면 세션이 풀린다

→ H2 기반으로 스프링이 재실행 될 때 H2도 재시작하기 때문

이후 AWS로 배포하게 되면 AWS의 DB인 RDS를 사용하게 되니 이때부터는 세션이 풀리지 않는다

5.6 네이버 로그인

네이버 API 등록

네이버 오픈 API에서 다음과 같이 애플리케이션을 등록한다

application-oauth.properties

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

# Naver
# 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

Client ID 와 Secret을 등록한다

네이버는 구글과 다르게 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력한다

spring.security.oauth2.client.provider.naver.user-name-attribute=response

  • 기본적으로 스프링 시큐리티에서 회원 결과는 하위 필드를 명시할 수 없고 최상위 필드들만 user_name으로 지정 가능하다
  • 네이버의 로그인의 응답은 JSON 형태로 resultCode, message, response의 세 필드 중 response 필드에 로그인 정보를 담고 있으므로 이를 선택한다
  • 이후 자바 코드로 response의 id를 user_name으로 지정해줄 것이다

스프링 스큐리티 설정 등록

구글 로그인을 개발하면서 확장성을 염두해 두었으므로 OAuthAttributes에서 네이버인지 판단하는 코드와 네이버 생성자만 추가해준다

OAuthAttributes.java

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

    public 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();
    }

return ofNaver("id", attributes);

  • response의 id를 user_name으로 지정

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

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-secondary active" role="button">Naver Login</a>
{{/userName}}

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

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

기존: 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성

→ 시큐리티 옵션이 활성화되면 인증된 사용자만 API 호출 가능

테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정

전체 테스트 실행: Gradle > Tasks > verification > test

테스트에 실패한 문제들을 하나씩 해결해본다

문제 1. CustomOAuth2UserService 찾을 수 없음

hello가_리턴된다 테스트에서 CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없어서 발생하는 문제

src/main 환경과 src/test 환경의 차이 때문에 발생

src/main/resources/application.properties 까지는 test에 해당 파일이 없을 시 main의 환경을 그대로 가져오기 때문에 테스트 수행이 가능하나, application-oauth.properties 는 test에 파일이 없다고 가져오는 파일이 아니다

test/resources/application.properties 를새롭게 생성하고 가짜 설정값을 등록한다

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

문제 2. 302 Status Code

Posts_등록된다 테스트

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

이런 API 요청은 임의로 인증된 사용자를 추가하여 테스트

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

spring-security-test 를 build.gradle에 추가한다

build.gradle

testCompile('org.springframework.security:spring-security-test')

PostsApiControllerTest.java

그리고 PostsApiControllerTest의 2개의 테스트 메소드에 임의 사용자 인증을 추가한다

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

@WithMockUser(roles = "USER")

  • 인증된 모의 사용자를 만들어 사용
  • roles에 권한 추가 가능
  • 이 어노테이션으로 인해 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가질 수 있다

@WithMockUser 는 MockMvc에서만 작동하기 떄문에 @SpringBootTest 로만 되어있는 PostsApiControllerTest 에선 바로 사용할 수 없다

@SpringBootTest 에서 MockMvc를 사용하기 위해 다음과 같이 변경한다

PostsApiControllerTest.java

    @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);
//
//        //then
//        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
//        assertThat(responseEntity.getBody()).isGreaterThan(0L);
//
//        List<Posts> all = postsRepository.findAll();
//        assertThat(all.get(0).getTitle()).isEqualTo(title);
//        assertThat(all.get(0).getContent()).isEqualTo(content);

        //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 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);

        //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);
    }

@Before

  • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성

mvc.perform

  • 생성된 MockMvc를 통해 API를 테스트

문제 3. @WebMvcTest에서 CustomOAuth2UserService 찾을 수 없음

문제 1에서 여전히 문제가 발생한다

HelloControllerTest는 @WebMvcTest 를 사용한다

문제 1을 통해 스프링 시큐리티 설정은 잘 했지만, @WebMvcTest 는 CustomOAuth2UserService를 스캔하지 않는다

@WebMvcTestWebSecurityConfigurerAdapter, WebMvcConfigurer 를 비롯한 @ControllerAdvice, @Controller 를 읽는다

하지만 @Repository, @Service, @Component 는 스캔의 대상이 아니다

SecurityConfig는 읽었지만 SecurityConfig 생성에 필요한 CustomOAuth2UserService는 읽을 수 없어서 발생한 문제이다

해결

먼저, 스캔 대상에서 SecurityConfig를 제거한다

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

여기서도 마찬가지로 @WithMockUser 를 사용해서 가짜로 인증된 사용자를 생성

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

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

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

Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty!

이 에러는 @EnableJpaAuditing 으로 인해 발생한다

@EnableJpaAuditing 을 사용하기 위해선 최소 하나의 @Entity 클래스가 필요하나 @WebMvcTest 이다 보니 당연히 없다

@EnableJpaAuditing@SpringBootApplication 과 같이 있으니 @WebMvcTest 에서도 스캔하게 되었다

그래서 @EnableJpaAuditing@SpringBootApplication 을 분리한다

Application.java

package com.vencott.dev.springboot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

// @EnableJpaAuditing
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Application.java에서 @EnableJpaAuditing 를 제거해주고 config 패키지에 JpaConfig를 생성해 @EnableJpaAuditing 를 추가한다

JpaConfig.java

package com.vencott.dev.springboot.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

출처: 이동욱 저, 『스프링 부트와 AWS로 혼자 구현하는 웹 서비스』, 프리렉(2019)


좋은 웹페이지 즐겨찾기