[F-Lab 모각코 챌린지 53일차] OAuth 로그인 구현

부추·2023년 7월 24일
0

F-Lab 모각코 챌린지

목록 보기
53/66

0. 연동 준비

google OAuth 기능을 이용하기 위해 resouce server(=oauth provider)에 가서 OAuth를 위한 client key를 가져오자. 그 뒤 application.yml을 다음과 같이 구성한다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_OAUTH_ID}
            client-secret: ${GOOGLE_OAUTH_PW}
            scope:
              - email
              - profile

${GOOGLE_OAUTH_ID}, ${GOOGLE_OAUTH_PW}로 설정된 것은 IntelliJ 빌드시 환경 변수로 등록한 상수인데, GCP에서 client-specific한 OAuth2 인증을 위해 받은 API key와 비밀번호이다.
IDE의 요부분을 클릭해서 "Edit Configuration"의 "Environment variables"에 key-value 값으로 각각을 추가하면 된다.
(추가한 모습)


1. CustomOAuth2UserService

이제 resource server로부터 받은 OAuth2 인증 정보를 파싱한 뒤, 우리의 security context에 인증 정보를 저장할 차례다. Spring Security의 OAuth2 Client 라이브러리를 이용한다. OAuth2UserService<OAuth2UserRequest, OAuth2User>는 resource server로부터 사용자가 인증 토큰을 받으면, 해당 토큰을 가지고 우리 서버로 redirect 요청을 날린 사용자 정보를 파싱하는 역할을 한다. 해당 일을 하는 것이 loadUser() 메소드이다.

@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 {
        // 1. resource server 정보 추출
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        String provider = userRequest.getClientRegistration().getRegistrationId();

        // 2. resource server 에서 provider specific attribute, key field 추출
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        // 3. general attribute 추출
        OAuthAttributes oAuthAttributes = OAuthAttributes
                .of(provider, userNameAttributeName, oAuth2User.getAttributes());

        // 4. attributes 토대로 회원가입 or 로그인 user 정보 가져오기
        User loggedInUser = saveIfNew(oAuthAttributes);

        // 5. session에 현재 로그인한 유저 정보 저장
        httpSession.setAttribute("user", SessionUser.fromEntity(loggedInUser));

        // 6. security context에 등록할 oauth2 user return
        return new DefaultOAuth2User(
                Collections.singleton(loggedInUser.createAuthority()),
                oAuthAttributes.getAttributes(),
                oAuthAttributes.getUserNameAttributeName());
    }

    @Transactional
    private User saveIfNew(OAuthAttributes oAuthAttributes) {
        return userRepository.findByEmail(oAuthAttributes.getEmail())
                .orElseGet(() -> userRepository.save(oAuthAttributes.toEntity()));
    }
}

자세한 과정은 코드 주석으로 달아놨다. 1번은 기본으로 Request를 파싱하는 DefaultOAuth2UserService()로부터 provider의 이름을(일반적으로 구글인지? 카카오인지? 네이버인지?) 가져오는 과정, 2번은 Request로부터 OAuth2유저의 정보와 id field를 뽑아오는 과정이다.

3번부터 설명이 필요하다.


2. OAuthAttributes

우리 프로젝트에서 사용되는 user attribute이다. 사용자를 구분하기 위해 emailname(닉네임) 필드를 가지고, 어떤 resource server로부터 왔는지 구분을 위한 provider, key값인 userNameAttributeName, 마지막으로 resource server로부터 그대로 받은 attribute(Map)을 필드로 가진다.

@Builder
@Getter
public class OAuthAttributes {
    private String email;
    private String name;
    private String provider;
    private String userNameAttributeName;
    private Map<String,Object> attributes;

    /**
     * @param provider name of provider
     * @param attributes provider specific attributes
     * @return general attributes
     * */
    public static OAuthAttributes of(String provider,
                                     String userNameAttributeName,
                                     Map<String,Object> attributes) {
        OAuthProvider foundProvider = OAuthProvider.findProvider(provider);
        return foundProvider.convert(userNameAttributeName, attributes);
    }

    /**
     * 회원가입 user entity
     * default {@link Role} : GUEST
     * */
    public User toEntity() {
        return User.builder()
                .email(this.email)
                .name(this.name)
                .provider(this.provider)
                .role(Role.GUEST)
                .build();
    }
}
  • of()provider를 인자로 받아, resource server로부터 받은 provider-specific attributes를 우리 프로젝트에서 이용하는 OAuthAttributes로 바꿔주는 역할을 한다. 실질질적으로 convert() 메소드가 해당 역할을 수행한다.
  • findProvider()를 통해 String 값에 맞는 OAuthProvider enum 객체가 찾아진다.

enum OAuthProvider에는 현재엔 GOOGLE만이 존재한다. 코드를 까보자.

/**
 * oauth provider와
 * 그에 맞는 attribute converter를 제공합니다.
 * */
@RequiredArgsConstructor
public enum OAuthProvider {
    GOOGLE("google", (userNameAttributeName, attributes) -> {
        return OAuthAttributes.builder()
                .email((String) attributes.get("email"))
                .name((String) attributes.get("name"))
                .userNameAttributeName(userNameAttributeName)
                .provider("google")
                .attributes(attributes)
                .build();
    });

    private final String name;
    private final OAuthAttributeConverter converter;

    public static OAuthProvider findProvider(String name) {
        return Arrays.stream(OAuthProvider.values())
                .filter(provider -> provider.hasEqualName(name)).findAny()
                .orElseThrow(() -> new OAuth2AuthenticationException("유효하지 않은 provider : " + name));
    }

    public OAuthAttributes convert(String userNameAttributeName, Map<String, Object> attributes) {
        return converter.convert(userNameAttributeName,attributes);
    }

    private boolean hasEqualName(String name) {
        return this.name.equals(name);
    }
}
  • 필드로 provider name, 그리고 attributes를 OAuthAttributes로 바꾸는 converter가 존재한다.
  • converter의 인자는 key field와 resource server로부터 받은 attributes 자체이다. 해당 attributes에서 key field를 이용해 OAuthAttributes 객체를 만들어 return한다.

추가로, converter 자체는 함수형 인터페이스이다.

@FunctionalInterface
public interface OAuthAttributeConverter {
    /**
     * @param userNameAttributeName provider server에서 이용하는 id field
     * @param attributes provider server에서 제공한 user attributes
     * @return : recipetory에서 사용하는 {@link OAuthAttributes} 타입의 DTO
     */
    OAuthAttributes convert(String userNameAttributeName, Map<String,Object> attributes);
}

3. saveIfNew()

OAuth를 통해 로그인한 유저가 원래 우리 프로젝트 DB에 존재하는 유저인지, 아닌지 판단하는 과정이 필요하다. 만약 처음 로그인한 유저라면 회원가입 처리를, 그렇지 않다면 그냥 해당 유저를 불러와야한다. 해당 과정은 같은 클래스 내부의 private User saveIfNew() 메소드를 참고하자! 나름 Optional을 이용해보았다. DB에 존재하지 않아 null이 반환된다면, OAuthAttributes 정보를 토대로 domain의 User 객체를 새로 만들어 repository에 save()한 뒤 결과를 return한다.


4. Session 등록

HttpSession 빈을 주입받은 후, setAttribute()메소드를 통해 "user"키로 세션에 등록했다.

그 뒤 loadUser()의 반환값으로 DefaultOAuth2User() 객체를 생성해서 등록했는데, 생성자로 들어간 인자는 다음과 같다.

  1. grantedAuthority : Spring Security에서 인가에 사용하는 필드이다. "ROLE"로 시작하는 것이 관례이며, OAuthAttributes.toEntity()를 봤으면 알겠지만 처음 가입하는 유저의 경우 Role.GUEST를 default 값으로 갖는다.
  2. attributes : Principle 자체라고 볼 수도 있다.

5. SessionUser

세션에 등록된 세션유저 객체인 SessionUser는 email과 nickname(=name)으로 구성된 간단한 객체이다.

@Getter
@Builder
public class SessionUser {
    private String name;
    private String email;

    public static SessionUser fromEntity(User user) {
        return SessionUser.builder()
                .email(user.getEmail())
                .name(user.getName())
                .build();
    }
}

그런데 해당 유저가 필요해질때마다 HttpSession 빈을 불러오기 귀찮아서, argument resolver를 사용하기로 했다.

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

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

    /**
     * 두가지 조건을 검사하여 SessionUser를 method arg에 binding합니다.
     * 1. handler method의 parameter에 @LogInUserrk 붙었는지
     * 2. 해당 parameter type이 String이 맞는지
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isEmailAnnotation = parameter
                .getParameterAnnotation(LogInUser.class) != null;
        boolean isString = parameter
                .getParameterType().equals(SessionUser.class);

        return isEmailAnnotation && isString;
    }

    // arg에 실제 SessionUser의 email 속성 binding
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }

HandlerMethodArgumentResolver는 다음 WebConfiguration에 의해 등록된다.

@RequiredArgsConstructor
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
    private final LogInUserArgumentResolver logInUserArgumentResolver;

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

6. SecurityConfiguration

이번 프로젝트에서 제일 삽질한놈 ㄱ-. Spring Security 6.1.2부터는 Security Configuration이 조금 바뀌었다. 전반적으로 lambda를 이용하게 됐고, mvc와 연동을 위해 그냥 matcher pattern이 아닌 MvcRequestMatcher.Builder를 따로 사용해야 했다. 그를 위해 introspector 빈을 추가해야하는 것은 필수..부들부들

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {

    private final CustomOAuth2UserService customOAuth2UserService;

    // since spring security 6.1.2 : MvcRequestMatcher.Builder
    // https://github.com/spring-projects/spring-security-samples/tree/main/servlet/java-configuration/authentication/preauth
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(mvc.pattern("/check")).authenticated()
                        .anyRequest().permitAll())
                // for h2-console
                .headers(headers -> headers.frameOptions(option -> option.disable()))
                .csrf(csrf -> csrf.disable())
                .oauth2Login(oauth -> oauth
                        .userInfoEndpoint(userInfo ->
                                userInfo.userService(customOAuth2UserService)))
                .logout(logout -> logout
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true));

        return http.build();
    }

    @Bean
    MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
        return new MvcRequestMatcher.Builder(introspector);
    }
}

위 코드에서 제일 중요한 것은oauth2Login() 부분이다. userInfoEndPoint()에 방금 우리가 쭉 만든 customOAuth2UserService가 곱게 위치해있다.


REFERENCE

카카오 사이트

profile
부추튀김인지 부추전일지 모를 정도로 빠싹한 부추전을 먹을래

0개의 댓글