[개인 프로젝트] 구글 소셜 로그인 구현

Turtle·2024년 8월 23일
0

1. Google 관련 설정

다른 블로그 및 강의에도 관련 설정 부분은 잘 나와있는 관계로 이 부분은 넘어가겠다.
설정이 끝났다면 application.properties 혹은 application.yml 파일에 구글 소셜 로그인 관련 설정을 추가해준다.

참고로 application.properties 혹은 application.yml 파일은 노출에 민감한 정보들이 포함되므로 .gitignore를 통해 관리를 해야 한다.

server:
  port: 8080
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost/~/db
    username: sa
    password:
  h2:
    console:
      enabled: true
  jpa:
    defer-datasource-initialization: true
    hibernate:
      ddl-auto: update
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 구글 클라이언트 ID값
            client-secret: 구글 클라이언트 Secret값
            scope:
              - email
              - profile

logging:
  level:
    org:
      hibernate:
        sql: debug
        #type: trace

https://developers.google.com/?hl=ko

2. OAuth2UserInfo

OAuth 2.0 제공자들마다 제공해주는 값은 상이하다. 구현체 클래스에 직접적으로 의존하는 코드는 유지보수하기 어렵다. OAuth 2.0 제공자들이 공통으로 제공하는 값만을 추출하여 상위 인터페이스로 정의한 후 이 인터페이스를 구현하는 방법으로 코드를 구현했다.

public interface OAuth2UserInfo {
	String getProviderId();
	String getProvider();
	String getEmail();
	String getName();
}

3. GoogleUserInfo

@Slf4j 로그 어노테이션을 추가하여 구글에서 어떤 정보를 제공하는지를 확인해보았다. 아래와 같은 정보들이 제공되는 것을 확인할 수 있다.

2024-08-23T20:08:48.402+09:00  INFO 16424 --- [nio-8080-exec-9] p.b.c.o.PrincipalOauthDetailsService     : 구글 로그인 요청
2024-08-23T20:08:48.402+09:00  INFO 16424 --- [nio-8080-exec-9] p.b.c.o.PrincipalOauthDetailsService     : OAuth2User.Google={sub= { sub값 }, name= { 가입자 이름 } , given_name= { 이름 }, family_name={ 성 }, picture={ 구글 프로필 }, email= { 이메일 }, email_verified=true}

제공하는 정보를 보면 sub, name, given_name, family_name, picture, email, email_verified 값들이 보인다. 상위 인터페이스인 OAuth2UserInfo을 구현한 GoogleUserInfo를 구현한다.

@AllArgsConstructor
public class GoogleUserInfo implements OAuth2UserInfo{

	private Map<String, Object> attributes;

	@Override
	public String getProviderId() {
		return (String) attributes.get("sub");
	}

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

	@Override
	public String getEmail() {
		return (String) attributes.get("email");
	}

	@Override
	public String getName() {
		return (String) attributes.get("name");
	}
}

4. PrincipalOauthDetailsService

일반 로그인 서비스와 별개로 OAuth 2.0 로그인 서비스를 제공할 서비스 계층 코드를 구현한다.
소셜 로그인 서비스로 자동 로그인 및 강제 회원가입 가능 여부를 확인하기 위해 외부 로그 라이브러리인 gavlyukovskiybuild.gradle에 추가 후 코끼리 버튼을 누른다.

결과를 확인해보면 INSERT 쿼리문이 호출되는 것을 확인할 수 있다.

그러나 이 코드에는 한 가지 문제가 존재한다. 바로 계정의 중복 여부이다. 현재 코드에는 스프링 시큐리티 일반 회원가입으로 등록된 계정과 소셜 로그인의 계정이 중복되어도 둘 다 들어가는 것을 확인할 수 있다. 이 부분은 네이버와 카카오 소셜 로그인을 구현한 이후 별도의 업데이트 메서드를 따로 추가해 해결하여 포스팅을 남길 예정이다.

@Slf4j
@Service
@RequiredArgsConstructor
public class PrincipalOauthDetailsService extends DefaultOAuth2UserService {

	private final MemberRepository memberRepository;

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2User oAuth2User = super.loadUser(userRequest);
		return processOAuth2User(userRequest, oAuth2User);
	}

	private OAuth2User processOAuth2User(OAuth2UserRequest request, OAuth2User oAuth2User) {
		OAuth2UserInfo oAuth2UserInfo = null;

		if (request.getClientRegistration().getRegistrationId().equalsIgnoreCase("google")) {
			log.info("구글 로그인 요청");
			log.info("OAuth2User.Google={}", oAuth2User.getAttributes());
			oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
		} else if (request.getClientRegistration().getRegistrationId().equalsIgnoreCase("kakao")) {
			log.info("카카오 로그인 요청");
			log.info("OAuth2User.Kakao={}", oAuth2User.getAttributes());
			oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
		} else if (request.getClientRegistration().getRegistrationId().equalsIgnoreCase("naver")) {
			log.info("네이버 로그인 요청");
			log.info("OAuth2User.Naver={}", oAuth2User.getAttributes());
			oAuth2UserInfo = new NaverUserInfo((Map) oAuth2User.getAttributes().get("response"));
		}

		Optional<Member> findMember = memberRepository.findByEmail(oAuth2UserInfo.getEmail());
		Member member;

		// 소셜 로그인 시 계정 중복 여부를 검증
		if (findMember.isPresent()) {
			log.info("해당 이메일로 가입한 계정이 존재합니다.");
			member = findMember.get();
			update(member, oAuth2UserInfo);
		} else {
			log.info("해당 이메일로 가입한 계정이 존재하지 않습니다. 소셜 로그인과 동시에 회원가입이 자동으로 진행됩니다.");
			// OAuth 2.0 유저의 경우 패스워드가 없음
			member = Member.builder()
					.username(oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId())
					.email(oAuth2UserInfo.getEmail())
					.role(Role.ROLE_USER)
					.provider(oAuth2UserInfo.getProvider())
					.providerId(oAuth2UserInfo.getProviderId())
					.build();
		}
		memberRepository.save(member);
		return new PrincipalDetails(member.getEmail(), member.getRole(), oAuth2User.getAttributes());
	}

	// 소셜 로그인 중복 계정 가입 시 → username, provider, providerId, modified_date 정보만 변경
	// 이외의 데이터는 그대로 유지되도록
	private Member update(Member member, OAuth2UserInfo oAuth2UserInfo) {
		member.setUsername(oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId());
		member.setProvider(oAuth2UserInfo.getProvider());
		member.setProviderId(oAuth2UserInfo.getProviderId());
		return memberRepository.save(member);
	}
}

5. SecurityConfig

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	private final CustomAuthenticationProvider customAuthenticationProvider;
	private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;
	private final PrincipalOauthDetailsService principalOauthDetailsService;

	public SecurityConfig(CustomAuthenticationProvider customAuthenticationProvider,
						  CustomAuthenticationFailureHandler customAuthenticationFailureHandler,
						  PrincipalOauthDetailsService principalOauthDetailsService) {
		this.customAuthenticationProvider = customAuthenticationProvider;
		this.customAuthenticationFailureHandler = customAuthenticationFailureHandler;
		this.principalOauthDetailsService = principalOauthDetailsService;
	}

	@Bean
	public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
		return authenticationConfiguration.getAuthenticationManager();
	}

	@Bean
	public SessionRegistry sessionRegistry() {
		return new SessionRegistryImpl();
	}

	@Bean
	public CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler(SessionRegistry sessionRegistry) {
		return new CustomAuthenticationSuccessHandler(sessionRegistry);
	}

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
				.requestCache(cache -> cache.requestCache(new NullRequestCache()))
				.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
						.sessionConcurrency(concurrency -> concurrency.maximumSessions(1)
								.maxSessionsPreventsLogin(false)
								.sessionRegistry(sessionRegistry())
								.expiredUrl("/login?expired")))
				.authenticationProvider(customAuthenticationProvider)
				.csrf(csrf -> csrf.disable())
				.authorizeHttpRequests(auth -> auth
						.requestMatchers(
								new AntPathRequestMatcher("/js/**"),
								new AntPathRequestMatcher("/img/**"),
								new AntPathRequestMatcher("/css/**"),
								new AntPathRequestMatcher("/auth/login"),
								new AntPathRequestMatcher("/auth/signup"),
								new AntPathRequestMatcher("/login"),
								new AntPathRequestMatcher("/signup")
						).permitAll()
						.anyRequest().authenticated()
				)
				.formLogin(form -> form
						.loginPage("/auth/login")
						.loginProcessingUrl("/login")
						.successHandler(customAuthenticationSuccessHandler(sessionRegistry()))
						.failureHandler(customAuthenticationFailureHandler)
						.permitAll()
				)
				.logout(logout -> logout
						.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
						.logoutSuccessUrl("/auth/login")
						.invalidateHttpSession(true)
						.deleteCookies("JSESSIONID")
				)
				.oauth2Login(oauth -> oauth
						.userInfoEndpoint(userInfo -> userInfo
								.userService(principalOauthDetailsService)
						)
						.defaultSuccessUrl("/board/posts")
				);

		return http.build();
	}
}

0개의 댓글