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

❗️OAuth2 Client를 사용한다면 위 2~5번의 과정을 간편하게 진행할 수 있습니다(아마도..)❗️
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에 관련된 의존성을 추가합니다.
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에 등록이 되어있기때문에 따로 작성안해주셔도 됩니다.
@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 필드를 추가했습니다.
public enum Provider {
GOOGLE, KAKAO, APPLE
}
public enum Authority {
ROLE_GUEST, ROLE_USER, ROLE_ADMIN
}
Authority는 3가지로 분류됩니다.
- 회원가입을 한 후 아직 약관동의를 하지 않은 회원 : ROLE_GUEST
- 회원가입을 한 후 약관동의를 한 회원 : ROLE_USER
- 관리자 : ROLE_ADMIN
public interface MemberRepository extends JpaRepository <Member, Long> {
Member findByProviderAndProviderId(Provider provider, String providerId);
}
@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());
}
public interface OAuth2UserInfo {
String getProviderId();
String getProvider();
Map<String, Object> getAttributes();
}
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;
}
}
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에 존재합니다.
애플로직은 상당히 다르기에 이 포스트에서는 구글,카카오만 우선적으로 처리하겠습니다.
@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의 속성들이 담기게 됩니다.
@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 토큰) 구현과정을 설명할 예정입니다. 감사합니다!