다른 블로그 및 강의에도 관련 설정 부분은 잘 나와있는 관계로 이 부분은 넘어가겠다.
설정이 끝났다면 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
OAuth 2.0 제공자들마다 제공해주는 값은 상이하다. 구현체 클래스에 직접적으로 의존하는 코드는 유지보수하기 어렵다. OAuth 2.0 제공자들이 공통으로 제공하는 값만을 추출하여 상위 인터페이스로 정의한 후 이 인터페이스를 구현하는 방법으로 코드를 구현했다.
public interface OAuth2UserInfo {
String getProviderId();
String getProvider();
String getEmail();
String getName();
}
@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");
}
}
일반 로그인 서비스와 별개로 OAuth 2.0 로그인 서비스를 제공할 서비스 계층 코드를 구현한다.
소셜 로그인 서비스로 자동 로그인 및 강제 회원가입 가능 여부를 확인하기 위해 외부 로그 라이브러리인 gavlyukovskiy
를 build.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);
}
}
@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();
}
}