회원관리 feat OAuth2.0

Jongmyung Choi·2023년 8월 21일
0
post-thumbnail

VoteSKill 프로젝트가 궁금하시면 이전 글을 참고해주세요.


개요


VoteSKill의 로그인 과정이다.
카카오 로그인을 통해 회원가입 이나 로그인을 할 수 있게 하였다.
구현 과정중 공부한 내용을 정리하기 위해 글을 작성하게 되었다.

OAuth2.0 로그인 과정


이미지 출처: PAYCO 개발자센터

Spring Security의 OAuth2.0 인증 방식을 자세하게 살펴보면 다음과 같다

Resource Owner는 사용자(VoteSKill의 Front-end)
Client는 서비스(VoteSKill 의 Auth Server)
Authorization Server는 PAYCO(KAKAO) 인증서비스
Resource Server는 PAYCO(KAKAO)의 API 서비스 이다.

1. 사용자는 VoteSKill의 Auth Server 로 접근 권한을 요청한다.

2. 유저가 카카오 로그인 버튼을 클릭하면 서비스 (VoteSKill의 Auth Server)에서 카카오의 인증서비스로 요청을 한다.

3. 카카오의 인증서비스 는 사용자에게 로그인 페이지를 띄워준다.

4. 사용자는 해당 로그인 페이지에서 ID / PW 를 입력하여 로그인을 진행한다.

5. 로그인에 성공하면 카카오 인증서비스는 사용자 에게 Authorization Code를 발급해준다.

6. 사용자가 우리의 Auth Server에게 Redirect Callback URL로 발급받은 Authorization Code를 담아서 요청

7,8 . Auth Server에서 카카오 서비스에 Authroization Code를 이용하여 Access Token을 요청하고 발급 받는다.

OAuth2.0 with Spring Security and JWT

설정

카카오 로그인을 위한 애플리케이션 등록과정에 대한 설명은 생략하고 작성했다.
또한 Kakao는 Spring Security에서 제공하는 서비스가 아니기 때문에 환경변수를 작성해야 하는데 그 부분도 생략 하였다.

카카오 로그인을 구현하기 위하여 spring-boot-starter-oauth2-clientSpring Security 를 사용하였다.
이를 위해 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

또한 Spring Security에 설정을 해주어야 한다.
Config 파일을 생성하고

 @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors().and()
          .formLogin().disable()
          .httpBasic().disable()
          .csrf().disable()
          .cors()
          .headers().frameOptions().disable()
          .and()
          .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
          .and()
        .authorizeRequests()
            .antMatchers("/oauth","/css/**","/images/**","/js/**","/favicon.ico","/h2-console/**").permitAll()
            .antMatchers("/users","/v3/api-docs/**","/swagger-ui/**","/")
        .permitAll()
        .anyRequest().authenticated()
        .and()
        .oauth2Login()
        .successHandler(oAuth2LoginSuccessHandler) 
        .failureHandler(oAuth2LoginFailureHandler)
        .userInfoEndpoint().userService(customOAuth2UserService);

    http.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
    http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonUsernamePasswordAuthenticationFilter.class);

    return http.build();
  }
  @Bean
  public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.addAllowedOriginPattern("*");
    configuration.addAllowedHeader("*");
    configuration.addAllowedMethod("*");
    configuration.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
  }
  @Bean
  public PasswordEncoder passwordEncoder() {

    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }
  @Bean
  public AuthenticationManager authenticationManager() {

    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setPasswordEncoder(passwordEncoder());
    provider.setUserDetailsService(loginService);

    return new ProviderManager(provider);
  }
  @Bean
  public LoginSuccessHandler loginSuccessHandler() {

    return new LoginSuccessHandler(jwtService, userRepository);
  }
  @Bean
  public LoginFailureHandler loginFailureHandler() {

    return new LoginFailureHandler();
  }
  @Bean
  public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordAuthenticationFilter() {

    CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordLoginFilter = new CustomJsonUsernamePasswordAuthenticationFilter(
        objectMapper);
    customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
    customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessHandler());
    customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());

    return customJsonUsernamePasswordLoginFilter;
  }
  @Bean
  public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {

    JwtAuthenticationProcessingFilter jwtAuthenticationFilter = new JwtAuthenticationProcessingFilter(jwtService,
        userRepository);

    return jwtAuthenticationFilter;
  }

설정 파일 설명

  • .cors()는 cors설정을 커스텀 하기위해 작성하였다.( CorsConfigurationSource에서 설정 )
  • .oauth2Login() 을 설정 하면 OAuth2LoginConfigurer가 활성화 된다.
  • .successHandler.failureHandler는 로그인이 성공되거나 실패했을때 실행되는 핸들러이며 성공시에는 JWT 토큰을 응답으로 리턴하고 실패시엔 실패 상태코드를 리턴한다.
  • OAuth2LoginConfigurer가 활성화 되고나서 /oauth2/authorization/kakao 로 요청을 보냈을 때 OAuth2LoginAuthenticationProviderauthenticate()가 실행된다.
  • 여기서 실행되는 loadUser()를 변경하기 위하여
    .userInfoEndpoint().userService(customOAuth2UserService) 를 설정하였다.
  • 필터 설정을 통하여
    LogoutFilter -> JwtAuthenticationProcessingFilter -> CustomJsonUsernamePasswordAuthenticationFilter 순으로 작동하게 하였다.

핵심 클래스

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

  private final UserRepository userRepository;

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

    String registrationId = userRequest.getClientRegistration().getRegistrationId();
    String userNameAttributeName = userRequest.getClientRegistration()
        .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); 
    Map<String, Object> attributes = oAuth2User.getAttributes(); 

    OAuthAttributes extractAttributes = OAuthAttributes.of( userNameAttributeName, attributes);

    UserEntity createdUser = getUser(extractAttributes); 

    return new CustomOAuth2User(
        Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())),
        attributes,
        extractAttributes.getNameAttributeKey(),
        createdUser.getEmail(),
        createdUser.getRole());
  }
  private UserEntity getUser(OAuthAttributes attributes, SocialType socialType) {
    UserEntity findUser = userRepository.findBySocialTypeAndSocialId(socialType,
        attributes.getOauth2UserInfo().getId()).orElse(null);

    if (findUser == null) {
      return saveUser(attributes, socialType);
    }
    return findUser;
  }
  private UserEntity saveUser(OAuthAttributes attributes, SocialType socialType) {
    UserEntity createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo());
    return userRepository.save(createdUser);
  }

CustomOAuth2UserService 클래스 설명
loadUser() 는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서
사용자 정보를 얻은후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다.
결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저

Oauth 로그인 시 회원 여부

Oauth로 로그인을 했을 때 기존 회원인지 아닌지 여부에 따라서 프론트는 회원가입창 또는 메인으로 이동 시켜야 한다.
세가지 방법을 생각해봤다.

1. 로그인 시 기존 회원인지 조사후 JWT Token 발급

로그인 시 디비에 해당 유저가 존재하는지 확인후 존재하지 않는다면 회원가입을 위한 JWT Token을 발급해주고 회원가입 요청시에 해당 토큰을 담아 요청한다.

2. 로그인 시 기존 회원인지 조사하고 리다이렉트 시킨다.

로그인 시 해당 유저가 회원가입을 하지 않은 상태라면 프론트의 회원가입 페이지로 리다이렉트 시킨다.

3. 최초 로그인시 디비에 저장하고 유저의 상태를 PENDING 으로 설정한다.

최초 로그인 시(회원가입을 하지 않은상태)에 유저를 디비에 저장하지만 유저의 상태를 PENDING 으로 설정한다.
이후 회원가입시 해당 유저가 PENDING 상태인지 확인하고 회원가입을 하면 USER로 변경시킨다.

여기서 2번 케이스는 적합하지 않다고 생각했다.
왜냐하면 2번 케이스는 단순히 리다이렉트를 시키는것이기 때문에 사용자가 url을 통해 자유롭게 접속해서 요청을 보낼 수 있다.
따라서 1,3번 방법 두개다 구현해봤는데 회원가입을 했을 때만 디비에 저장하는게 적합하다고 생각하여 1번을 선택했다.

참고

https://ksh-coding.tistory.com/63
https://ttl-blog.tistory.com/97

profile
총명한 개발자

0개의 댓글