[Spring] SpringBoot 3.X + OAuth2 Client + Spring Security + JWT 소셜로그인 구현(애플, 카카오, 구글) - 1

Chan_hee·2024년 4월 26일

✍🏻   소개

이전 프로젝트를 진행할 때 OAuth2.0 을 활용하여 소셜로그인을 진행하였지만 OAuth2 Client를 활용하지 못하고 구현만 하면 된다는 식으로 REST형식으로 주먹구구하게 진행하였기에 스파게티 코드가 되어버렸습니다.. 이번 프로젝트에서는 확실하게 공부하고 구현 후 기록을 남기고자 글을 작성하게 되었습니다.


💡   전반적 로그인 FLOW

  1. 사용자가 웹 화면의 소셜로그인 버튼 클릭(애플, 카카오, 구글)합니다.
  2. 사용자가 해당 플랫폼의 아이디와 비밀번호를 입력 후 로그인합니다.
  3. Resource Server(애플, 카카오, 구글)가 쿼리파라미터에 인가코드를 담아 redirect URL로 리다이렉트합니다.
  4. Authorization Server(어플리케이션 서버)는 인가코드를 활용하여 Resource Server에 엑세스토큰 요청 후 응답 받습니다.
  5. Authorization Server는 엑세스토큰을 활용하여 해당 플랫폼 사용자 정보 요청 후 응답 받습니다.
  6. 응답 받은 사용자 정보로 Authorization Server의 자체 엑세스/리프레쉬 토큰 Client(어플리케이션 프론트)에게 응답합니다.

❗️OAuth2 Client를 사용한다면 위 2~5번의 과정을 간편하게 진행할 수 있습니다(아마도..)❗️


build.gradle

dependencies {

    //security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    //OAuth2.0
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

build.gradle에 security와 OAuth2 Client에 관련된 의존성을 추가합니다.

application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: {kakao-client-id}
            redirect-uri: "https://{domain}/login/oauth2/code/kakao"
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
          google:
            client-id: {google-client-id}
            client-secret: {google-client-secret}
            redirect-uri: "https://{domain}/login/oauth2/code/google"
            scope:
              - email
              - profile
          apple:
            client-id: {Sevice Identifier}
            client-secret: AuthKey_{keyId}.p8
            redirect-uri: "https://{domain}/login/oauth2/code/apple"
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Apple
            scope:
              - name
              - email

        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user_name_attribute: id
          apple:
            authorizationUri: https://appleid.apple.com/auth/authorize?scope=name%20email&response_mode=form_post
            tokenUri: https://appleid.apple.com/auth/token

  우선 각 플랫폼별로 서비스등록 하는 과정은 검색하면 많이 나오기에 생략하겠습니다.

application.yml파일을 크게 두부분으로 나눌 수 있습니다.

  • registration : 각 플랫폼 서비스 계정 정보들 및 redirect URL, scope를 설정합니다.
  • provider : 엑세스토큰이나 인가코드를 받아오는 URL을 설정합니다.

위의 yml파일을 작성하면 {domain}/oauth2/authorization/{provider} URL로 접속 시 해당 플랫폼 로그인 폼으로 리다이렉트 되는것이 활성화됩니다. 이후 사용자가 로그인 후 yml에 작성해놓은 redirect-uri로 인가코드가 전달이됩니다. 만약 REST방식으로 구현했다면 제가 직접 인가코드를 받는 컨트롤러를 만들어야 하겠지만 OAuth2 Client를 사용하였기에 그럴 필요없이 yml에 작성해준 {domain}/login/oauth2/code/{provider}로 리다이렉트되어 OAuth2 Client가 알아서 처리하게 됩니다.
(단, 위 리다이렉트 형식을 꼭 지켜야합니다!)

위의 yml파일에서 provider 부분에 google이 없어서 의문점을 가지실 수 있습니다. facebook, google과 같은 대중적인 플랫폼은 이미 spring oauth2 provider에 등록이 되어있기때문에 따로 작성안해주셔도 됩니다.

Member.java

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Enumerated(EnumType.STRING)
    private Authority authority;
    @Enumerated(EnumType.STRING)
    private Provider provider;
    private String providerId;
    private String nickName;
}

기본적인 Member 클래스입니다. 추후 기존 회원여부 판단을 위해 provider와 providerId 필드를 추가했습니다.

Provider.java

public enum Provider {
    GOOGLE, KAKAO, APPLE
}

Authority.java

public enum Authority {
    ROLE_GUEST, ROLE_USER, ROLE_ADMIN
}

Authority는 3가지로 분류됩니다.

  • 회원가입을 한 후 아직 약관동의를 하지 않은 회원 : ROLE_GUEST
  • 회원가입을 한 후 약관동의를 한 회원 : ROLE_USER
  • 관리자 : ROLE_ADMIN

MemberRepository.java

public interface MemberRepository extends JpaRepository <Member, Long> {

   Member findByProviderAndProviderId(Provider provider, String providerId);
}

PrincipalOauth2UserService.java

@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserInfo oAuth2UserInfo = null;

        oAuth2User = super.loadUser(userRequest);
            if (userRequest.getClientRegistration().getRegistrationId().equals("google"))
                oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
            else if (userRequest.getClientRegistration().getRegistrationId().equals("kakao"))
                oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
       
        String provider = oAuth2UserInfo.getProvider().toUpperCase();
        String providerId = oAuth2UserInfo.getProviderId();

        Member member = memberRepository.findByProviderAndProviderId(Provider.valueOf(provider),providerId);

        if(member == null)
        {
            String authority = "ROLE_GUEST";
            String nickName = provider + "_" + providerId;
            member = Member.builder()
                    .authority(Authority.valueOf(authority))
                    .nickName(nickName)
                    .provider(Provider.valueOf(provider))
                    .providerId(providerId)
                    .build();
            memberRepository.save(member);
            return new PrincipalDetails(member,oAuth2UserInfo.getAttributes());
        }
        return new PrincipalDetails(member, oAuth2UserInfo.getAttributes());
    }

OAuth2UserInfo.java

public interface OAuth2UserInfo {
    String getProviderId();
    String getProvider();
    Map<String, Object> getAttributes();
}

GoogleUserInfo.java

public class GoogleUserInfo implements OAuth2UserInfo{
    private Map<String,Object> attributes;
    public GoogleUserInfo(Map<String,Object> attributes){
        this.attributes = attributes;
    }
    @Override
    public String getProviderId() {
        return (String)attributes.get("sub");
    }
    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }
}

KakaoUserInfo.java

public class KakaoUserInfo implements OAuth2UserInfo{
    private Map<String,Object> attributes;
    public KakaoUserInfo(Map<String,Object> attributes){
        this.attributes = attributes;
    }
    @Override
    public String getProviderId() {
        return attributes.get("id").toString();
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }
}

코드를 차근차근 설명해보겠습니다. 위 코드는 DefaultOAuth2UserService를 상속받은 커스텀한 Oauth2Service 객체를 생성한것입니다. loadUser 매서드를 오버라이드 해서 커스텀하게 각 플랫폼 별 사용자 정보를 받아와 memberRepository에 저장하였습니다. loadUser매서드가 실행되는 시점은 Oauth2 Client가 인가코드를 활용하여 provider의 엑세스토큰을 응답받은 상태에서 실행됩니다. 이 엑세스토큰과 provider에 대한 정보는 loadUSer매서드 매개변수 OAuth2UserRequest에 존재합니다.

loadUser 매서드 실행 플로우

  1. super.loadUser(userRequest)를 호출하여 사용자정보를 받아옵니다.
  2. provider에 따라서 분기합니다.
  3. OAuth2User에 담겨있는 사용자정보(Map 형식)를 각 provider에 맞게 구현한 OAuth2UserInfo 객체에 담습니다.
    -> 구글과 카카오는 고유아이디를 저장한 필드명이 다릅니다.(구글은 sub, 카카오는 id) 이렇기에 OAuth2UserInfo 인터페이스를 정의하고 각 Provider에 맞게 구현해주었습니다.
  4. findByProviderAndProviderId 매서드로 기존회원인지 확인합니다.
  5. 신규회원이면 ROLE_GUEST의 권한으로 임시 닉네임과 provider, providerId를 적용하여 memberRepository에 저장합니다.
  6. PrincipalDetails를 리턴합니다.

애플로직은 상당히 다르기에 이 포스트에서는 구글,카카오만 우선적으로 처리하겠습니다.

PrincipalDetails.java

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class PrincipalDetails implements UserDetails, OAuth2User {
    private Member member;
    private Map<String,Object> attributes;

    public PrincipalDetails(Member member){
        this.member = member;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(member.getAuthority().toString()));
        return authorities;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return member.getId().toString();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String getName() {
        return member.getId().toString();
    }
}

  이전 loadUser 매서드에서 갑자기 리턴이 Oauth2User라는 객체인데 PrincipalDetails을 리턴하여서 의문이 드실 수 있습니다. PrincipalDetails를 설명하기 앞서 Spring Security의 Authentication객체에 대해 이해가 필요합니다. 간단히 말씀드리면 Spring Security의 SecurityContextHolder에 Authentication이라는 객체가 담겨져 있습니다. 이 Authentication 속에 사용자 정보들을 담아 로그인 시 활용할 수 있습니다. 그런데 Authentication이 담을 수 있는 구현객체는 Oauth2User, UserDetails라는 두가지 객체입니다. 우리는 PrincipalDetails객체가 이 두가지 객체를 구현하도록 하여 최종적으로 Authenticaition에 두 객체를 구현한 PrincipalDetails객체를 담을 것 입니다. 그림으로 본다면 다음과 같습니다.


최종적으로 PrincipalDetails에는 저장된 Member와 Oauth2User의 속성들이 담기게 됩니다.

SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
    private final PrincipalOauth2UserService principalOauth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf((auth) -> auth.disable())
                .headers(h -> h.frameOptions(f -> f.sameOrigin()))
                .cors((co)->co.configurationSource(configurationSource()))
                .formLogin((auth) -> auth.disable())
                .httpBasic((auth)->auth.disable())
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**").permitAll()
                        .requestMatchers("/api/auth/**").permitAll()
                        .anyRequest().authenticated())
                .oauth2Login(oauth2Login -> oauth2Login
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(principalOauth2UserService))
                .sessionManagement(sm->sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
    @Bean
    public CorsConfigurationSource configurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOriginPattern("*"); // 모든 IP 주소 허용
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*"); // GET, POST, PUT, DELETE (Javascript 요청 허용)
        configuration.setAllowCredentials(true); // 클라이언트에서 쿠키 요청 허용
        configuration.addExposedHeader("Authorization");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

Spring Boot3 버전 이후 Security Config파일 작성 형식이 람다를 활용하는것으로 바뀌었습니다... 그래서 레퍼런스들이 부족하여 작성하는데 힘들었지만 시행착오 끝에 성공하게 되어 뿌듯합니다... CorsConfigurationSource를 빈으로 등록하여 CORS에러를 방지하였습니다.


🤓 마무리

이번 포스트는 우선 구글,카카오 소셜로그인을 OAuth2 Client를 활용하여 컨트롤러 작성없이 구현해보았습니다. 다음 포스트에는 애플로그인 구현 과정을 설명할 예정이고 마지막으로 서버 자체 JWT(access/refresh 토큰) 구현과정을 설명할 예정입니다. 감사합니다!

0개의 댓글