[SPRING] 스프링에서 소셜로그인 적용하기~

wannabeing·2025년 6월 4일
0

SPRING

목록 보기
15/16
post-thumbnail

로그인 기능 적용하면서 새롬넴 코드 션하게 날려벌임..😇
스프링에서 소셜로그인을 구현하는 방법은 2가지가 있다.

1. 소셜(구글, 카카오)에서 제공하는 API 활용하기

인가코드 받기, 토큰 받기, 로그인처리까지 크게 3가지로 나뉜다.
소셜로그인 흐름을 확실하게 파악할 수 있다는 장점이 있지만,
소셜마다 제공하는 API가 다르기 때문에 여러개라면 각각 처리해야 한다는 단점이 있다.

2. 스프링시큐리티와 OAuth2 Client 라이브러리 사용하기

스프링부트에서 사용할 수 있는 라이브러리로, oauth2와 관련된 설정만 application-properties 파일에 입력하면 스프링이 인가과정을 도와준다.
따라서 구현에 용이하지만, 흐름을 파악하기 힘들다는 단점도 있다.

우리의 경우, 스프링에서 제공하는 라이브러리를 사용하기로 결정했다.
아래와 같은 이유가 있다.

  • 여러개의 소셜로그인 기능 구현을 위해 공통화된 작업을 하고 싶었다.
    → 유지보수 용이하고, 구현하지 않은 팀원들이 봐도 가독성이 좋다고 판단했다.
  • 소셜로그인 흐름 파악은 대략적으로 알고 있다고 판단했다.
  • 구현 시간을 줄이기 위해서
    → 우리 서비스에 로그인 기능이 꼭 있어야 되는 것이 아니기 때문이다.

인증/인가에 대하여

  • 인증 (Authentication)
    사용자가 누구인지 확인하는 과정 (ID/PW)
  • 인가 (Authorization)
    인증된 사용자가 특정 리소스에 접근할 수 있는 권한 부여 (ADMIN)

인증이 선행되어야, 인가처리를 할 수 있다.

  • 소셜로그인 간단 흐름
    유저가 직접 소셜 로그인을 통해 확실한 사람인지 인증받는다.
    → 우리 서비스는 이 과정에서 포함되지 않는다.

    인증이 유효하면, 유저의 권한을 서비스에게 인가해준다.
    → 우리 서비스가 유저의 소셜서비스를 대행할 수 있다.
    → 이 때, 유저는 권한을 부여하는 과정에서 포함되지 않는다.


OAuth2의 역할

  • Resource Owner: 유저
  • Client: 우리 서비스
  • Authorization/Resource Server: 소셜(구글, 카카오, 깃헙 등)

OAuth2를 사용하기 위한 설정

  • 각 소셜 개발자 사이트에서 발급받을 수 있다.
  • ClientID: 서비스의 식별자
  • SecretKey: 서비스가 신뢰할 수 있는지 확인하는 비밀번호와 같은 역할
  • RedirectURI: 권한을 부여할 때, 서비스가 받는 URI

OAuth2 WorkFlow

1. 인증과정

  • 사용자가 크롬(User-Agent)에 접속해서 서비스(Client)에 로그인하기 위해 소셜로그인 기능을 사용한다.
  • 소셜(Authorization)에서 로그인 페이지를 제공하여 사용자가 인증(Authentication)을 한다.
  • 인증할 때, 어떤 권한(Authorization)을 부여할건지 사용자가 직접 선택할 수도 있다.

  • 위 화면이 사용자에게 뜰 때까지의 흐름이다!

인가과정

  • 사용자와 소셜(AuthorizationServer) 사이에서 생성된 Authorization Code를 갖고, 서비스(Client)소셜(AuthorizationServer)에게 AccessToken(RefreshToken)을 발급받는다.
  • 이렇게 각각 인증/인가 세션에서 최소한의 인원으로 진행되는 이유는
    정보를 탈취되지 않기 위한 장치라고 생각하면 된다.

스프링시큐리티 + OAuth2 Client 라이브러리를 사용하여 구현해보자

application.yaml

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${client_id}
            client-secret: ${secret_key}
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - read:user
              - user:email
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: id
  • OAuth2와 관련된 설정을 추가해주자.
  • userName, Email만 가져올 예정이다.

라이브러리 추가

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

SpringSecurity 설정파일 추가

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private static final String PERMITTED_ROLES[] = {"USER", "ADMIN"};
    private final JwtTokenProvider jwtTokenProvider;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
        CustomOAuth2UserService customOAuth2UserService) throws Exception {
        return http

            .httpBasic(HttpBasicConfigurer::disable)
            // 모든 요청에 대해 보안 정책을 적용함 (securityMatcher 선택적)
            .securityMatcher((request -> true))

            // CSRF 보호 비활성화 (JWT 세션을 사용하지 않기 때문에 필요 없음)
            .csrf(AbstractHttpConfigurer::disable)

            // OAuth 사용으로 인해 기본 로그인 비활성화
            .formLogin(FormLoginConfigurer::disable)

            // 세션 사용 안함 (STATELESS)
            .sessionManagement(
                session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
						
			// HTTP 요청에 대한 접근 권한을 설정
            .authorizeHttpRequests(request -> request
                .requestMatchers("/oauth2/**", "/login/oauth2/code/**").permitAll()
                .requestMatchers("/subscription/**").permitAll()
                .anyRequest().hasAnyRole(PERMITTED_ROLES)
            )

			// OAuth2 처리
            .oauth2Login(oauth2 -> oauth2
            	/** .loginPage("/login") -> 커스텀 로그인 페이지 **/
                .successHandler(oAuth2LoginSuccessHandler)
                .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                .userService(customOAuth2UserService)
             )
                //.defaultSuccessUrl("/home", true) // 로그인 성공 후 이동할 URL
            )

            // JWT 인증 필터 등록
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter.class)

            // 최종 SecurityFilterChain 반환
            .build();
    }
}
  • loginPage: 커스텀 로그인 페이지
  • successHandler: OAuth2 로그인이 성공했을 때 실행되는 클래스
  • failureHandler: OAuth2 로그인이 실패했을 때 실행되는 클래스
  • userInfoEndPoint: 소셜로부터 받은 사용자정보를 누가 처리할지 명시
  • defaultSuccesUrl: 로그인 성공하면 이동할 페이지

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    private final UserRepository userRepository;

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

        // 서비스를 구분하는 아이디 ex) Kakao, Github ...
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        SocialType socialType = SocialType.from(registrationId);

        // 서비스에서 제공받은 데이터
        Map<String, Object> attributes = oAuth2User.getAttributes();

        OAuth2Response oAuth2Response = getOAuth2Response(socialType, attributes);
        userRepository.validateSocialJoinEmail(oAuth2Response.getEmail(), socialType);

        User loginUser = getUser(oAuth2Response);
        return new AuthUser(loginUser);
    }
    
    	// 소셜에 알맞는 OAuth2 응답객체를 생성하여 반환함
		private OAuth2Response getOAuth2Response(SocialType socialType, Map<String, Object> attributes) { ... }
		
        // OAuth2 응답객체로 User 찾아서 없으면 생성하고 있으면 해당 객체를 반환함
        private User getUser(OAuth2Response oAuth2Response) { ... }
}
  • 소셜로그인이 되면 먼저 CustomOAuth2UserService 구현체가 실행이 된다.

OAuth2User에 대하여

  • OAuth2User는 OAuth 로그인 시 받아오는 사용자 정보를 담은 인터페이스
  • 내부 getAttributes()에서 소셜 플랫폼마다 다른 사용자 정보를 Map 형태로 제공해줌.
  • OAuth 로그인 성공 시 SecurityContextHolder의 Authentication 객체로부터 이걸 꺼내 사용할 수 있음.

registrationId에 대하여

  • userRequest: 현재 OAuth2 로그인 요청에 대한 정보를 담고 있음.
  • getClientRegistration(): application.yml (또는 properties)에 등록한 소셜 로그인 설정 정보를 가져옴.
  • getRegistrationId(): application.yml에서 정의한 소셜 이름(ex: Kakao, Github)을 가져옴.

SocialType에 대하여

@RequiredArgsConstructor
@Getter
public enum SocialType {
    KAKAO("kakao_account", "id", "email"),
    GITHUB(null, "id", "login");

    private final String attributeKey; //소셜로부터 전달받은 데이터를 Parsing하기 위해 필요한 key 값,
                                        // kakao는 kakao_account안에 필요한 정보들이 담겨져있음.
    private final String providerCode; // 각 소셜은 판별하는 판별 코드,
    private final String identifier;   // 소셜로그인을 한 사용자의 정보를 불러올 때 필요한 Key 값

    // 어떤 소셜로그인에 해당하는지 찾는 정적 메서드
    public static SocialType from(String provider) {
        String upperCastedProvider = provider.toUpperCase();

        return Arrays.stream(SocialType.values())
            .filter(item -> item.name().equals(upperCastedProvider))
            .findFirst()
            .orElseThrow();
    }
}
  • 사용자가 어떤 소셜 로그인을 사용했는지, 소셜로그인 자체의 식별 코드에 대한 정보를 담고 있는 Enum 클래스이다.

OAuth2Response에 대하여

public interface OAuth2Response {
	// 소셜타입을 반환
	SocialType getProvider();

	// 이메일 값을 반환
	String getEmail();

	
	String getName();
}
  • 각각 소셜응답객체의 인터페이스이다.
  • 각각 Email, Name을 추출하는 데이터구조가 다르기 때문에 각각 구현해야 한다.

OAuth2GithubResponse (implements OAuth2Response)

@RequiredArgsConstructor
public class OAuth2GithubResponse implements OAuth2Response{
	private final Map<String, Object> attributes;

	@Override
	public SocialType getProvider() {
		return SocialType.GITHUB;
	}

	@Override
	public String getEmail() {
		try {
			return (String) attributes.get("email");
		} catch (Exception e){
			throw new IllegalStateException("깃허브 계정정보에 이메일이 존재하지 않습니다.");
		}
	}

	@Override
	public String getName() {
		try {
			String name = (String) attributes.get("name");
			return name != null ? name : (String) attributes.get("login");
		} catch (Exception e){
			throw new IllegalStateException("깃허브 계정정보에 이름이 존재하지 않습니다.");
		}
	}
}

OAuth2KakaoResponse (implements OAuth2Response)

@RequiredArgsConstructor
public class OAuth2KakaoResponse implements OAuth2Response{
	private final Map<String, Object> attributes;

	@Override
	public SocialType getProvider() {
		return SocialType.KAKAO;
	}

	@Override
	public String getEmail() {
		try {
			@SuppressWarnings("unchecked")
			Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
			return kakaoAccount.get("email").toString();
		} catch (Exception e){
			throw new IllegalStateException("카카오 계정정보에 이메일이 존재하지 않습니다.");
		}
	}

	@Override
	public String getName() {
		try {
			@SuppressWarnings("unchecked")
			Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
			return properties.get("nickname").toString();
		} catch (Exception e){
			throw new IllegalStateException("카카오 계정정보에 닉네임이 존재하지 않습니다.");
		}
	}
}

CustomOAuth2UserService에서 OAuth2Response 생성하는 메서드

private OAuth2Response getOAuth2Response(SocialType socialType, Map<String, Object> attributes) {
    if(socialType == SocialType.KAKAO) {
        return new OAuth2KakaoResponse(attributes);
    } else if(socialType == SocialType.GITHUB) {
        return new OAuth2GithubResponse(attributes);
    } else {
        throw new UserException(UserExceptionCode.UNSUPPORTED_SOCIAL_PROVIDER);
    }
}
  • Service단에서는 getOAuth2Response() 메서드로 생성한다.
  • 소셜에 따라 적절한 OAuth2Response 구현체를 생성하여 반환한다.
  • 소셜에 따라 제공하는 데이터 구조가 다르기 때문이다.

해당과정을 통해 User 엔티티를 생성하거나, DB에서 가져와
AuthUser 로 변환하여 반환한다.

이후에 OAuth2LoginSuccessHandler 는 AuthUser를 갖고, JWT 토큰을 발급하고 클라이언트에게 발급한 토큰을 응답하는 역할을 한다.

따라서

사용자 로그인 요청
↓
CustomOAuth2UserService 호출
↓
CustomOAuth2UserService.getOAuth2Response() 호출하여 각 소셜로그인 정보 반환
↓
OAuth2LoginSuccessHandler 전달 및 호출
↓
JWT 토큰 생성하고, AuthUser로 변환하여 SecurityContextHolder에 저장

출처

우테코 OAuth2.0
NHNCloud OAuth
OAuth2.0 구현하기 1
OAuth2.0 구현하기 2

profile
wannabe---ing

0개의 댓글