BOOK 2 - 스프링부트와 AWS로 혼자 구현하는 웹 서비스(4)

스프링부트와 AWS로 혼자 구현하는 웹 서비스 - 4

Spring Security와 OAuth2로 구글 로그인 기능 구현

Google Oauth2 설정

  • 많은 서비스에서 자체 로그인 기능을 구현하기보다 구글, 페이스북, 네이버 로그인 같은 소셜 로그인 기능을 사용한다.
    • 직접 로그인 기능을 구현할 경우 본인 인증 , 로그인 보안 , 비밀번호 찾기 , 비밀번호 변경 등의 기능들을 모두 구현해야 하는 복잡함이 있다.
    • Oauth2를 사용하여 로그인 기능 구현시 위 기능들을 따로 구현할 필요가 없어 서비스 개발에 집중할 수 있다.

Oauth2 client 생성

  • Oauth2 클라이언트는 https://console.cloud.google.com 에서 프로젝트 생성 후 생성할 수 있다.

    • 사용자에게 보여질 ‘OAuth 동의 화면’ 을 구성한 뒤 ‘승인된 리디렉션 URI’ 값을 설정해준다.

      image

      • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 redirect할 URL 정보
      • 스프링 부트2 버전의 시큐리티에서는 기본적으로 {domain}/login/oauth2/code/{소셜서비스코드} 로 redirect URL을 지원하고 있다.
      • Spring Security에서 이미 해당 URL을 지원하는 Controller를 구현해놓은 상태이기 때문에 사용자가 별도로 Controller를 구현할 필요 없다.

프로젝트에 oauth2 client 정보 설정

  • application-oauth.properties 생성

    spring.security.oauth2.client.registration.google.client-id=클라이언트 ID
    spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀
    spring.security.oauth2.client.registration.google.scope=profile,email
    
    • 스프링부트에서는 properties의 이름을 application-xxx.properties 로 만들면 xxx 라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다.
      • 즉, profile마다 해당 properties의 설정들을 가져올 수 있다.
  • application.properties 에 profile 설정 추가

    spring.profiles.include=oauth
    
  • oauth 클라이언트 정보(클라이언트 ID, 클라이언트 보안 비밀)는 외부에 노출되면 안되는 중요 정보이기 때문에 공개적인 github remote repository에 올라가서는 안된다.

    • .gitignore 파일에 application-oauth.properties 파일을 제외시키는 설정을 추가해준다.

      application-oauth.properties
      

프로젝트에 User Entity 생성 및 Spring-Security 설정

User 클래스

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Builder
    public User(String name, String email, String picture, Role role) {
        this.name = name;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    public User update(String name, String picture) {
        this.name = name;
        this.picture = picture;
        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}
  • @Enumerated(EnumType.STRING)
    • JPA로 DB에 데이터를 저장할 때 Enum 값을 어떤 형태로 저장할지 설정
    • 별도 설정해주지 않으면 기본적으로 int형 숫자가 저장된다.

Role 클래스

@Getter
@RequiredArgsConstructor
public enum Role {
    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}
  • Spring Security에서는 권한 코드에 항상 ROLE_ 이 앞에 들어가야만 한다.

UserRepository 인터페이스

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}
  • findByEmail : 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 판단하기 위해 user를 조회하는 메서드

spring-security dependency 설정

  • build.gradle 에 dependency 추가

    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
    

SecurityConfig 클래스

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .headers().frameOptions().disable()
            .and()
            .authorizeRequests()
            .antMatchers("/", "/images").permitAll()
            .antMatchers("/api/v1/**").hasRole(Role.USER.name())
            .anyRequest().authenticated()
            .and()
            .logout().logoutSuccessUrl("/")
            .and()
            .oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
    }
}
  • @EnableWebSecurity : Spring Security 설정을 활성화 시킨다.
  • csrf().disable().headers().frameOptions().disable() : h2-console 화면을 사용하기 위해 해당 옵션들을 disable 설정
  • authorizeRequests
    • URL 별 권한 관리를 설정하는 옵션의 시작점
    • antMatchers 옵션을 사용하기 위해서 선언
  • antMatchers
    • URL, HTTP 메서드별로 권한 관리 대상을 지정하는 옵션
    • "/" 등의 로그인이 필요없는 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 준다.
    • "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사용자만 접근 가능하도록 한다.
  • anyRequest
    • 설정된 값들 이외 나머지 URL들을 나타낸다.
    • authenticated() 옵션을 추가하여 나머지 URL들은 모두 인증된 사용자만 접근 가능하도록 한다.
  • logout().logoutSuccessUrl("/")
    • 로그아웃 기능에 대한 여러 설정의 진입점
    • 로그아웃 성공시 "/" 주소로 이동한다.
  • oauth2Login : OAuth2 로그인 기능에 대한 여러 설정의 진입점
  • userInfoEndpoint : OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정의 진입점
  • userService
    • OAuth2 로그인 성공 이후 후속 조치를 진행할 UserService interface의 구현체를 등록한다.
    • 소셜 서비스에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.

OAuth2Service를 사용한 로그인 기능 구현

CustomOAuth2UserService

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributionName = userRequest.getClientRegistration().getProviderDetails()
          .getUserInfoEndpoint().getUserNameAttributeName();

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributionName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);
        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                                     attributes.getAttributes(),
                                     attributes.getNameAttributeKey());
    }

    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
          .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
          .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}
  • registrationId
    • 현재 소셜 로그인 진행 중인 서비스를 구분하는 코드
    • 하나의 소셜 로그인만 사용시에는 불필요한 값이지만, 이후 네이버나 페이스북 등 다양한 확장을 고려했을 때 서비스를 구분하기 위해 사용한다.
  • userNameAttributeName
    • OAuth2 로그인 진행 시 키가 되는 필드값 (Primary Key와 같은 의미)
    • 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.
  • OAuthAttributes
    • DefaultOAuth2UserService 를 통해 가져온 OAuth2User의 attribute를 담을 클래스
    • 다른 소셜 로그인들과 공용으로 사용한다.
  • SessionUser
    • session에 사용자 정보를 저장하기 위한 DTO 클래스
    • User Entity 클래스와 별도로 생성하여 사용한다.
  • saveOrUpdate()
    • 소셜 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능을 추가한다.
    • 기존 소셜 사용자 정보가 서비스 DB에 없으면 소셜 사용자 정보를 User Entity로 변환 후 save 한다.

OAuthAttributes DTO 클래스

@Getter
public class OAuthAttributes {

    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes,
                           String nameAttributeKey,
                           String name,
                           String email,
                           String picture) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    public static OAuthAttributes of(String registrationId, String userNameAttributeName,
                                     Map<String, Object> attributes) {
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName,
                                            Map<String, Object> attributes) {
        return OAuthAttributes.builder()
          .name((String)attributes.get("name"))
          .email((String)attributes.get("email"))
          .picture((String)attributes.get("picture"))
          .attributes(attributes)
          .nameAttributeKey(userNameAttributeName)
          .build();
    }

    public User toEntity() {
        return User.builder()
          .name(name)
          .email(email)
          .picture(picture)
          .role(Role.GUEST)
          .build();
    }
}
  • toEntity()
    • OAuthAttributes 에서 User Entity를 생성하는 시점은 처음 가입할 때이다.
    • 가입할 때 기본 권한을 GUEST 로 주기 위해서 role 빌더 값에 Role.GUEST 값을 설정해준다.

SessionUser DTO 클래스

@Getter
public class SessionUser implements Serializable {

    private String name;
    private String email;
    private String picture;

    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}
  • SessionUser 에는 인증된 사용자 정보만 필요하다.

User Entity 클래스와 SessionUser DTO 클래스를 분리해서 사용하는 이유

  • User 클래스를 그대로 세션에 저장하도록 했을 경우 다음과 같은 에러가 발생한다.

    Failed to convert from type [java.lang.Object] to type [byte[]] for value '...User'
    
    • User 클래스에 Serializable 을 구현하지 않았다는 의미의 에러이다.
  • Entity 클래스는 다른 엔티티와 관계가 형성될 여지가 있다.

    • @OneToMany , @ManyToMany 등 자식 엔티티를 갖고 있으면 직렬화 대상에 자식 엔티티들까지 모두 포함되어 성능 이슈, 부수 효과가 발생할 수 있다.
  • Serializable interface를 구현한 session DTO를 하나 추가로 만드는 것이 운영 및 유지보수에 좋다.

OAuth2 로그인 기능 구현 개선

Annotation 기반 SessionUser 설정

LoginUser Annotation 클래스

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

}
  • @Target(ElementType.PARAMETER)
    • 해당 annotation이 생성될 수 있는 위치 지정
    • PARAMETER : 메서드의 파라미터로 선언된 객체에서만 사용할 수 있다.
  • @interface
    • 해당 클래스를 Annotation 클래스로 지정
    • 즉, LoginUser 라는 이름을 가진 어노테이션이 생성되었다는 의미이다.

LoginUserArgumentResolver 클래스

@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");
    }
}
  • HandlerMethodArgumentResolver : 조건에 맞는 경우 해당 인터페이스의 구현체가 지정한 값으로 해당 메서드의 parameter로 넘길 수 있다.
  • supportsParameter()
    • 컨트롤러 메서드의 특정 parameter를 지원하는지 판단한다.
    • @LoginUser annotation이 붙어 있고, 클래스 타입이 SessionUser.class 인 경우 true를 반환한다.
  • resolveArgument() : parameter에 전달할 객체를 생성한다.

WebConfig Configuration 클래스

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

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}
  • LoginUserArgumentResolver 를 스프링에서 인식될 수 있도록 WebMvcConfigurer 를 구현한 구현체에 추가한다.

Session 저장소를 DB로 변경

  • 지금까지의 구현은 appllication을 재실행하면 사용자 로그인이 해제된다.
    • session이 내장 톰캣의 메모리에 저장되기 때문이다.
    • 따라서 application 실행 시 실행되는 내장 톰캣 같은 경우 세션이 항상 초기화된다.
  • 또한, 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야 한다.

  • 문제점을 해결하기 위한 두가지 방식이 있다.
    • MySQL과 같은 DB를 세션 저장소로 사용
      • 여러 AWS 간의 공용 세션을 사용할 수 있는 가장 쉽고 간단한 방법
      • 로그인 요청마다 DB I/O가 발생하여 성능 이슈가 발생할 수 있다는 단점이 있다.
      • 로그인 요청이 많이 없는 서비스에서 간단하게 사용하기 좋다.
    • Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용
      • 실제 B2C 서비스에서 가장 많이 사용하는 방식

spring-session-jdbc dependency 추가

  • build.gradle 에 dependency 추가

    compile('org.springframework.session:spring-session-jdbc')
    
  • application.properties 에 세션 저장소 설정 추가

    spring.session.store-type=jdbc
    
  • JPA로 인해 session table이 DB에 자동 생성된다.

네이버 로그인 기능 구현

Oauth2 client 생성

  • https://developers.naver.com/apps/#/register 에서 application 등록 후 서비스 URL, Callback URL 설정

image

프로젝트에 oauth2 client 정보 설정

  • application-oauth.properties 파일에 네이버 클라이언트 정보 설정

  • 네이버에서는 spring-security를 공식 지원하지 않기 때문에 provider 값들도 전부 수동으로 입력해야 한다.

    # naver registration
    spring.security.oauth2.client.registration.naver.client-id=클라이언트 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
      
    # 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
    
    • 네이버에서는 userNameAttributeNameresponse 가 된다.
      • spring-security에서는 최상위 필드만 user_name으로 지정 가능하다.
      • 네이버 회원 조회시 반환되는 JSON 데이터에서 최상위 필드는 resultCode , message , response 이고, 이 중 사용자 정보를 담고 있는 필드인 responseuser_name 으로 지정한다.

Spring-Security 설정

  • 구글 로그인 기능 구현에서 코드를 확장성 있게 구현했기 때문에 네이버 로그인을 추가할 때는 OAuthAttributes 클래스에서 registrationId 값으로 네이버인지 판단해서 데이터 생성하는 부분만 추가해주면 된다.

OAuthAttributes 클래스

public class OAuthAttributes {
  ...
  
  private static final String NAVER = "naver";
  
  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();
  }
}

기존 Test Code에 Spring-Security 적용

  • 기존의 API Test code들이 모두 인증에 대한 권한을 받지 못하였으므로 테스트 코드마다 인증한 사용자가 호출한 것처럼 수정해야 한다.

test 환경의 설정 파일 생성

  • src/main 환경과 src/test 환경은 본인만의 환경 구성을 가진다.
    • 그러나 test 환경에서 application.properties 파일이 없는 경우 main 환경의 설정을 그대로 가져오게 된다.
    • 다만, 자동으로 가져오는 설정의 범위는 application.properties 파일까지이며, application-oauth.properties 파일은 가져오지 않기 때문에 소셜 로그인 관련 설정값들이 존재하지 않는 문제가 발생한다.
  • src/test 밑에 테스트 환경을 위한 application.properties 파일을 생성하여 가짜 oauth 설정값을 등록한다.

application.properties 파일

spring.jpa.show-sql=true
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

API Test Code에 인증된 사용자 추가

  • ReviewControllerTest 의 테스트 코드를 실행하면 응답 결과로 302 FOUND 가 나와서 테스트가 실패한다.
    • 이는 Spring-Security 설정으로 인증되지 않은 사용자의 요청은 redirect 시키기 때문이다.
    • 인증이 필요한 API의 테스트 코드에는 임의로 인증된 사용자를 추가하여 테스트할 수 있도록 한다.

spring-security-test dependency 추가

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

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

ReviewControllerTest

test method에 임의 사용자 인증 추가
@Test
@WithMockUser(roles = "USER")
public void save_review() throws Exception {
  ...
}
  • @WithMockUser(roles = "USER")
    • 인증된 mock user를 만들어서 사용한다.
    • ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과
MockMvc 사용하여 API 호출
  • @WithMockUser annotation은 MockMvc 에서만 작동하기 때문에 @SpringBootTest 로만 사용하는 현재 코드에서는 작동하지 않는다.
  • 기존 TestRestTemplate 대신 MockMvc 를 사용하여 API를 요청하도록 수정해야 한다.
public class ReviewControllerTest {
  ...
    
  @Autowired
  private WebApplicationContext context;

  private MockMvc mvc;

  @Before
  public void setup() {
      mvc = MockMvcBuilders.webAppContextSetup(context)
      .apply(springSecurity())
      .build();
  }
  
  ...
  public void save_review() throws Exception {
    ...
    
    // when
    mvc.perform(post(url)
    .contentType(MediaType.APPLICATION_JSON)
    .content(new ObjectMapper().writeValueAsString(requestDto)))
       .andExpect(status().isOk());
    
    ...
  }
  
  ...
    
  public void update_review() throws Exception {
    ...
    
    // when
    mvc.perform(post(url)
    .contentType(MediaType.APPLICATION_JSON)
    .content(new ObjectMapper().writeValueAsString(requestDto)))
       .andExpect(status().isOk());
    
    ...
  }
}
  • @Before : 테스트가 시작되기 전에 MockMvc instance를 생성한다.
  • mvc.perform
    • MockMvc 를 통해 API를 테스트한다.
    • Body 영역은 문자열로 표현하기 위해 ObjectMapper 를 통해 문자열 JSON으로 변환한다.

@WebMvcTest 에서 component scan 문제 수정

  • 현재 HelloControllerTest 에서는 @WebMvcTest 를 사용하고 있다.
  • @WebMvcTestWebMvcConfigurer , @Controller , @ControllerAdvice 를 스캔한다.
    • 즉, @Repository , @Service , @Component 는 스캔 대상이 아니다.
    • 따라서 SecurityConfig 는 스캔했지만 해당 컴포넌트를 생성하기 위해 필요한 CustomOAuth2UserService 를 스캔하지 못해 에러가 발생한다.
  • @WebMvcTest 의 스캔 대상에서 SecurityConfig 를 제거하도록 한다.

HelloControllerTest

Component Scan 필터링
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
  @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
})
public class HelloControllerTest {
  ...
}
test method에 임의 사용자 추가
@WithMockUser(roles = "USER")
public void return_hello_with_get_request() throws Exception {
  ...
}

@EnableJpaAuditing 분리

  • @EnableJpaAuditing 을 사용하기 위해서는 최소 하나의 @Entity 클래스가 필요하다.
    • @WebMvcTest 는 해당 컴포넌트를 스캔하지 않기 때문에 역시 에러가 발생한다.
  • @EnableJpaAuditing annotation이 @SpringBootApplication 과 함께 있다보니 @WebMvcTest 에서 함께 스캔된다.
    • 따라서 @EnableJpaAuditing 을 분리하여 새로운 config 파일을 생성하도록 한다.
AllReviewApplication 클래스
// @EnableJpaAuditing 삭제
@SpringBootApplication
public class AllReviewApplication {
  ...
}
JpaConfig Configuration 클래스
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}